Packages

fnord

0.9.37

AI code archaeology

Current section

Files

Jump to
fnord lib memory scope_policy.ex
Raw

lib/memory/scope_policy.ex

defmodule Memory.ScopePolicy do
@moduledoc """
Defines long-term scope rules for memories.
This module decides whether a memory may live in a given long-term scope and
whether automatic scope transitions are allowed. The indexer and consolidator
both consult these rules so that scope decisions follow the same policy.
"""
@reserved_global_titles ["Me"]
@long_term_scopes [:global, :project]
@automatic_move_min_signal_count 2
@doc """
Returns true when the title identifies a reserved long-term memory.
"""
@spec reserved_title?(String.t()) :: boolean()
def reserved_title?(title) when is_binary(title) do
title in @reserved_global_titles
end
@doc """
Returns true when the memory is reserved from automatic scope moves.
"""
@spec reserved_from_automatic_move?(Memory.t()) :: boolean()
def reserved_from_automatic_move?(%Memory{title: title}) do
reserved_title?(title)
end
@doc """
Returns the allowed long-term scopes for the given title.
Reserved titles may be pinned to a single scope, while ordinary memories may
live in either long-term scope.
"""
@spec allowed_scopes_for_title(String.t()) :: [Memory.scope()]
def allowed_scopes_for_title(title) when is_binary(title) do
case title do
"Me" -> [:global]
_ -> @long_term_scopes
end
end
@doc """
Returns true when the memory is allowed to live in the target scope.
"""
@spec valid_target_scope?(Memory.t(), Memory.scope()) :: boolean()
def valid_target_scope?(%Memory{title: title}, target_scope) do
target_scope in allowed_scopes_for_title(title)
end
@doc """
Returns true when the memory has enough project-specific evidence to be
considered for an automatic move from global to project scope.
"""
@spec automatic_move_candidate?(Memory.t()) :: boolean()
def automatic_move_candidate?(%Memory{} = memory) do
case {global_scope?(memory), reserved_from_automatic_move?(memory)} do
{true, false} ->
count_project_signals(memory) >= @automatic_move_min_signal_count
_ ->
false
end
end
@doc """
Returns the number of project-specific signals present across title, content,
and topics.
"""
@spec count_project_signals(Memory.t()) :: non_neg_integer()
def count_project_signals(%Memory{} = memory) do
[
project_signal_in_text?(memory.title),
project_signal_in_text?(memory.content),
project_signal_in_topics?(memory.topics)
]
|> Enum.count(& &1)
end
@doc """
Returns true when the memory may be moved automatically from one scope to
another.
Automatic scope moves are narrower than general scope validity. A memory may
be valid in a scope without being eligible for an unattended move.
"""
@spec allow_automatic_move?(Memory.t(), Memory.scope()) :: boolean()
def allow_automatic_move?(%Memory{} = memory, target_scope) do
case {memory.scope, target_scope, valid_target_scope?(memory, target_scope)} do
{:global, :project, true} -> automatic_move_candidate?(memory)
_ -> false
end
end
@doc """
Returns :ok when the memory is allowed in the target long-term scope.
Returns {:error, :project_scope_not_allowed} when the memory is not allowed
in the requested long-term scope and {:error, :invalid_scope} when the scope
value cannot be normalized.
"""
@spec validate_scope(Memory.t(), String.t() | atom()) ::
:ok | {:error, :project_scope_not_allowed} | {:error, :invalid_scope}
def validate_scope(%Memory{title: title}, scope) do
case normalize_scope(scope) do
{:ok, scope_atom} ->
allowed_scopes = allowed_scopes_for_title(title)
case Enum.member?(allowed_scopes, scope_atom) do
true -> :ok
false -> {:error, :project_scope_not_allowed}
end
:error ->
{:error, :invalid_scope}
end
end
@doc """
Returns true when the target title and scope form a valid long-term target.
Non-binary titles return false.
"""
@spec valid_long_term_target?(String.t(), String.t() | atom()) :: boolean()
def valid_long_term_target?(title, scope) when is_binary(title) do
case normalize_scope(scope) do
{:ok, scope_atom} -> scope_atom in allowed_scopes_for_title(title)
:error -> false
end
end
def valid_long_term_target?(_, _), do: false
defp global_scope?(%Memory{scope: :global}), do: true
defp global_scope?(%Memory{}), do: false
defp project_signal_in_topics?(topics) when is_list(topics) do
Enum.any?(topics, &project_signal_in_text?/1)
end
defp project_signal_in_topics?(_), do: false
defp project_signal_in_text?(text) when is_binary(text) do
downcased_text = String.downcase(text)
path_signal?(downcased_text) or
workflow_term_signal?(downcased_text) or
code_identifier_signal?(text, downcased_text) or
scope_token_signal?(downcased_text)
end
defp project_signal_in_text?(_), do: false
defp path_signal?(text) do
Enum.any?(["lib/", "test/", "config/", "docs/", "scratch/"], &String.contains?(text, &1))
end
defp workflow_term_signal?(text) do
Regex.match?(~r/\bmix\s+/i, text) or
Regex.match?(~r/\bfnord\s+/i, text) or
Regex.match?(~r/\b(branch|ticket|deploy|workflow|module|component)\b/i, text)
end
defp code_identifier_signal?(original_text, downcased_text) do
String.contains?(original_text, "`") or
Regex.match?(
~r/\b[A-Z][A-Za-z0-9_]*(?:\.[A-Z][A-Za-z0-9_]*)+(?:\.[a-z_][A-Za-z0-9_]*[!?]?)?\b/,
original_text
) or
Regex.match?(~r/\b[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*[!?]?\b/, downcased_text)
end
defp scope_token_signal?(text) do
Regex.match?(~r/\b(consolidator|project|global|session)\b/i, text) or
Regex.match?(
~r/\bmemory\b\s*(?:scope|policy|store|index(?:er)?|consolidator|topic|topics|title|content)s?\b/i,
text
) or
Regex.match?(~r/\bcmd\b\s*[:\/]/i, text) or
Regex.match?(~r/\bfile_store\b/i, text)
end
@doc """
Normalizes a long-term scope value into an atom.
"""
@spec normalize_scope(String.t() | atom()) :: {:ok, Memory.scope()} | :error
def normalize_scope(scope) do
case scope do
:global -> {:ok, :global}
:project -> {:ok, :project}
"global" -> {:ok, :global}
"project" -> {:ok, :project}
_ -> :error
end
end
end