lib/openagents/memories/memory.ex

58e6347eeb72 · 10 KB

defmodule OpenAgents.Memories.Memory do
  @moduledoc """
  One thing the account asked to have remembered, or one thing the server
  learned on its behalf.

  A memory is account-scoped and authoritative. That is what separates it from
  the memory planes in `OpenAgents.Memory` (`docs/taxonomy.md`), which are
  disposable projections of messages that still exist underneath them: delete
  a memory and nothing can rebuild it, because the sentence a reader typed once
  is the only copy.

  Three buckets, kept distinct because they earn attention differently:

  * `user` — the reader said "remember that I prefer X". Explicit only, never
    inferred from what a turn happened to contain.
  * `learned` — server-side consolidation over thread events produced it. It
    carries `source_ref` so a wrong learning is traced back to the work that
    taught it.
  * `system` — what the network as a whole has learned. See
    `OpenAgents.Memories.Admissions`.

  A `system` row carries fields the other two do not, and they are required
  together: a `sys:` slug, a transparency `tier` of `ledger` or `glass`, an
  `as_of` date for the claim, an `admission` the author claims, and a non-empty
  `evidence_refs` list. A `user` or `learned` row carries none of them. Both
  halves of that rule are a database constraint (`memories_system_shape`) as
  well as a validation here, so an evidence-free candidate is unrepresentable
  rather than merely unwritten by the code that exists today.

  `admission` is the author's claim and nothing more. Effective status comes
  from `OpenAgents.Memories.Admissions.status/1`, which reads the admission
  records, so a row that says `admitted` with no steward record behind it still
  reads as a candidate.

  A system row may also carry a `stance`, and one that does is a **promotion
  tombstone**: the claim was promoted to a reviewed knowledge-base stance, this
  row supersedes the claim, and its body names the stance that replaced it. See
  `OpenAgents.Memories.Promotions`.

  `superseded_by_id` is how a correction lands. The replacement is a new row
  and the old row points at it, so the store keeps the chain rather than
  overwriting the mistake. Nothing here updates `body`: a memory's text is
  fixed for the life of the row.

  `user_id` is set on the struct and never cast, so a request body cannot name
  whose memory it is writing.
  """

  use Ecto.Schema

  import Ecto.Changeset

  alias OpenAgents.Accounts.User
  alias OpenAgents.Memories.Evidence

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  @timestamps_opts [type: :utc_datetime_usec]

  @buckets ~w(user learned system)
  @recallable_buckets ~w(user learned)
  @default_bucket "user"
  @body_characters 2_000
  @source_ref_characters 200
  @slug_characters 200
  @slug_prefix "sys:"
  @tiers ~w(ledger glass)
  @admissions ~w(candidate admitted rejected)
  @stance_characters 200
  # A knowledge-base stance id: lowercase words joined by hyphens, as the
  # corpus writes them (`earning-bitcoin`, `coder-tiers`).
  @stance_format ~r/^[a-z0-9]+(-[a-z0-9]+)*$/

  schema "memories" do
    belongs_to :user, User
    field :bucket, :string, default: "user"
    field :body, :string, redact: true
    field :source_ref, :string
    field :embedding, {:array, :float}
    field :embedding_model, :string

    # The system bucket's fields. Null on every other row.
    field :slug, :string
    field :entity, :string
    field :tier, :string
    field :as_of, :date
    field :admission, :string
    field :evidence_refs, {:array, :map}

    # The knowledge-base stance this claim was promoted to. A row carrying one
    # is a promotion tombstone: the claim's live home is the reviewed stance,
    # and this row exists to say so. Null on every other row.
    field :stance, :string

    # Whether this row is a promotion tombstone. PostgreSQL generates it from
    # `stance`, so it cannot disagree; it exists as a column because a foreign
    # key can reference one and an expression cannot. Read back after a write,
    # never written.
    field :promoted, :boolean, read_after_writes: true

    # The generated `tsvector` the lexical stand-in ranks over. PostgreSQL
    # writes it; nothing here reads it back, so it never rides a select.
    field :search_vector, :string, load_in_query: false

    # What `OpenAgents.Memories.Admissions` derived for this row, carried so a
    # note can print the status a steward's receipts produce rather than the
    # `admission` field the author claimed. Virtual on purpose: a derived
    # status has no column, because a column is exactly the thing an author
    # could write for themselves.
    field :derived_status, :string, virtual: true
    belongs_to :superseded_by, __MODULE__, foreign_key: :superseded_by_id
    timestamps()
  end

  @type t :: %__MODULE__{}

  @doc "The buckets a memory may be written into."
  @spec buckets() :: [String.t()]
  def buckets, do: @buckets

  @doc """
  The buckets account-scoped recall reads.

  `system` is not one of them and never becomes one. Reading an admitted system
  row into every account's turn is cross-account recall by construction, so it
  cannot ride the query that names `user_id`; it has a plane of its own in
  `OpenAgents.Memories.SystemRecall`, under an eligibility filter that replaces
  the scope predicate and a feature flag that is off by default (MEMORY-001).
  Widening this list would surface the bucket without either one, so it stays
  two buckets long.
  """
  @spec recallable_buckets() :: [String.t()]
  def recallable_buckets, do: @recallable_buckets

  @doc "The transparency tiers a system memory may carry."
  @spec tiers() :: [String.t()]
  def tiers, do: @tiers

  @doc "The admission states an author may claim."
  @spec admissions() :: [String.t()]
  def admissions, do: @admissions

  @doc "The kinds of evidence a system memory may cite."
  @spec evidence_kinds() :: [String.t()]
  def evidence_kinds, do: Evidence.kinds()

  @doc """
  Whether this row is a promotion tombstone.

  A tombstone is a pointer at a reviewed stance, not a claim of its own: no
  admission, challenge, or refutation may name one, and nothing can admit it, so
  it reaches no session's recall.
  """
  @spec promoted?(t()) :: boolean()
  def promoted?(%__MODULE__{stance: stance}), do: is_binary(stance)

  @doc "The longest stance id the store accepts, in characters."
  @spec stance_characters() :: pos_integer()
  def stance_characters, do: @stance_characters

  @doc "The prefix every system slug carries."
  @spec slug_prefix() :: String.t()
  def slug_prefix, do: @slug_prefix

  @doc "The bucket a write lands in when it names none."
  @spec default_bucket() :: String.t()
  def default_bucket, do: @default_bucket

  @doc "The longest body the store accepts, in characters."
  @spec body_characters() :: pos_integer()
  def body_characters, do: @body_characters

  @doc """
  Validates one new memory. The owner is not cast: pass it on the struct.
  """
  @spec changeset(t(), map()) :: Ecto.Changeset.t()
  def changeset(memory, attrs) do
    memory
    |> cast(attrs, [
      :bucket,
      :body,
      :source_ref,
      :embedding,
      :embedding_model,
      :slug,
      :entity,
      :tier,
      :as_of,
      :admission,
      :evidence_refs,
      :stance
    ])
    |> update_change(:body, &trim/1)
    |> update_change(:source_ref, &trim/1)
    |> update_change(:slug, &trim/1)
    |> update_change(:entity, &trim/1)
    |> update_change(:stance, &trim/1)
    |> validate_required([:bucket, :body])
    |> validate_inclusion(:bucket, @buckets)
    |> validate_length(:body, min: 1, max: @body_characters, count: :graphemes)
    |> validate_length(:source_ref, min: 1, max: @source_ref_characters, count: :graphemes)
    |> validate_bucket_fields()
    |> foreign_key_constraint(:user_id)
    |> check_constraint(:body, name: :memories_shape)
    |> check_constraint(:evidence_refs,
      name: :memories_system_shape,
      message: "does not satisfy the system-memory shape"
    )
  end

  # The system fields, required together on a system row and refused outright
  # on the other two. The database says the same thing in
  # `memories_system_shape`; this is the half that can explain itself to the
  # caller.
  defp validate_bucket_fields(changeset) do
    case get_field(changeset, :bucket) do
      "system" -> validate_system(changeset)
      _account_scoped -> refuse_system_fields(changeset)
    end
  end

  defp validate_system(changeset) do
    changeset
    |> validate_required([:slug, :tier, :as_of, :admission, :evidence_refs])
    |> validate_length(:slug, min: 1, max: @slug_characters, count: :graphemes)
    |> validate_format(:slug, ~r/^sys:/, message: "must start with #{@slug_prefix}")
    |> validate_length(:entity, min: 1, max: @slug_characters, count: :graphemes)
    |> validate_inclusion(:tier, @tiers)
    |> validate_inclusion(:admission, @admissions)
    |> Evidence.validate(:evidence_refs)
    |> validate_stance()
  end

  # A stance is optional — most system rows carry none — but a row that names
  # one is a promotion tombstone, and both halves of that shape are checked
  # here and again at the table. `position(stance in body) > 0` is the database
  # half of "a tombstone whose body names the stance"; this is the half that can
  # explain itself to the caller.
  defp validate_stance(changeset) do
    case get_field(changeset, :stance) do
      nil ->
        changeset

      stance ->
        changeset
        |> validate_length(:stance, min: 1, max: @stance_characters, count: :graphemes)
        |> validate_format(:stance, @stance_format,
          message: "must be a knowledge-base stance id, in lowercase words joined by hyphens"
        )
        |> validate_body_names(stance)
    end
  end

  defp validate_body_names(changeset, stance) do
    body = get_field(changeset, :body)

    if is_binary(body) and String.contains?(body, stance) do
      changeset
    else
      add_error(changeset, :body, "must name the stance this claim was promoted to")
    end
  end

  defp refuse_system_fields(changeset) do
    Enum.reduce(
      [:slug, :entity, :tier, :as_of, :admission, :evidence_refs, :stance],
      changeset,
      fn
        field, acc ->
          if is_nil(get_field(acc, field)) do
            acc
          else
            add_error(acc, field, "belongs only to a system memory")
          end
      end
    )
  end

  @doc "Points a memory at the memory that replaced it."
  @spec supersede_changeset(t(), t()) :: Ecto.Changeset.t()
  def supersede_changeset(memory, replacement) do
    memory
    |> change(superseded_by_id: replacement.id)
    |> check_constraint(:superseded_by_id, name: :memories_shape)
  end

  defp trim(value) when is_binary(value), do: String.trim(value)
  defp trim(value), do: value
end