diff --git a/.env.example b/.env.example index d512dc4fc1..7765f4a4d0 100644 --- a/.env.example +++ b/.env.example @@ -282,6 +282,15 @@ HINDSIGHT_API_LOG_LEVEL=info # Cross-encoder rerank of the fused candidates (false = use the RRF order): # HINDSIGHT_API_ENABLE_RERANKING=true +# Mental model refresh: let a delta refresh skip the agentic reflect loop. It +# reads the memories created since the last refresh first — an empty window +# preserves the document with no LLM call, a non-empty one becomes edit +# operations in a single call — and hands back to the loop whenever a surgical +# edit is not obviously safe, so the same outcomes stay reachable either way. +# Hierarchical, and overridable per mental model via trigger.delta_fast_path. +# Set false to always run the loop. +# HINDSIGHT_API_MENTAL_MODEL_DELTA_FAST_PATH=true + # Reranker Configuration (Optional - uses local by default) # Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference) # HINDSIGHT_API_RERANKER_PROVIDER=local diff --git a/hindsight-api-slim/hindsight_api/api/http.py b/hindsight-api-slim/hindsight_api/api/http.py index 6b81e4fbd8..ab842bdb3a 100644 --- a/hindsight-api-slim/hindsight_api/api/http.py +++ b/hindsight-api-slim/hindsight_api/api/http.py @@ -183,7 +183,7 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any: RecallScores, TokenUsage, ) -from hindsight_api.engine.search.tags import TagGroup, TagsMatch +from hindsight_api.engine.search.tags import TagGroup, TagsMatch, validate_entity_leaf_placement from hindsight_api.engine.structured_output import validate_response_schema from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension from hindsight_api.liveness import LivenessResponse, liveness_response @@ -366,6 +366,13 @@ def validate_tags_exclusive(self) -> "RecallRequest": raise ValueError("'tags' and 'tag_groups' are mutually exclusive. Use 'tag_groups' for compound filtering.") return self + @field_validator("tag_groups") + @classmethod + def validate_recall_entity_leaf_placement(cls, v: list[TagGroup] | None) -> list[TagGroup] | None: + # Entity leaves may not sit under 'not' -- see validate_entity_leaf_placement. + validate_entity_leaf_placement(v) + return v + class RecallResult(BaseModel): """Single recall result item.""" @@ -1030,6 +1037,13 @@ def validate_tags_exclusive(self) -> "ReflectRequest": raise ValueError("'tags' and 'tag_groups' are mutually exclusive. Use 'tag_groups' for compound filtering.") return self + @field_validator("tag_groups") + @classmethod + def validate_reflect_entity_leaf_placement(cls, v: list[TagGroup] | None) -> list[TagGroup] | None: + # Entity leaves may not sit under 'not' -- see validate_entity_leaf_placement. + validate_entity_leaf_placement(v) + return v + class ReflectFact(BaseModel): """A fact used in think response.""" @@ -2196,9 +2210,22 @@ class MentalModelTrigger(BaseModel): description=( "Compound boolean tag expressions to use during refresh instead of the model's own tags. " "When set, these tag groups are passed to reflect and the model's flat tags are NOT used for filtering. " - "Supports nested and/or/not expressions for complex tag-based scoping." + "Supports nested and/or/not expressions for complex tag-based scoping, plus entity leaves " + "({entities: [names], match: any|all}) that scope by what a memory is ABOUT rather than which " + "tag compartment it lives in -- matched case-insensitively against canonical entity names, " + "including entities reached through an observation's source memories. The same expressions " + "drive the staleness gate, so an entity-scoped model refreshes exactly when facts about its " + "entities arrive. Entity leaves may not appear under 'not'." ), ) + + @field_validator("tag_groups") + @classmethod + def validate_trigger_entity_leaf_placement(cls, v: list[TagGroup] | None) -> list[TagGroup] | None: + # Entity leaves may not sit under 'not' -- see validate_entity_leaf_placement. + validate_entity_leaf_placement(v) + return v + include_chunks: bool | None = Field( default=None, description=( @@ -2220,6 +2247,18 @@ class MentalModelTrigger(BaseModel): "None means use the bank/global config default (recall_chunks_max_tokens)." ), ) + delta_fast_path: bool | None = Field( + default=None, + description=( + "Override whether a delta refresh may take the deterministic fast path: fetch the " + "memories created since the last refresh and, if there are any, turn them into edit " + "operations with a single LLM call instead of running the agentic reflect loop. An " + "empty window costs no LLM call at all. The fast path hands back to the loop whenever " + "a surgical edit is not obviously safe, so every outcome is preserved either way. " + "None means use the bank/global config default (mental_model_delta_fast_path). " + "Ignored in full mode, which never takes the fast path." + ), + ) response_schema: dict | None = Field( default=None, description=( diff --git a/hindsight-api-slim/hindsight_api/config.py b/hindsight-api-slim/hindsight_api/config.py index d5195938ef..04cd57bdb9 100644 --- a/hindsight-api-slim/hindsight_api/config.py +++ b/hindsight-api-slim/hindsight_api/config.py @@ -797,6 +797,9 @@ def _parse_worker_slot_reservations() -> dict[str, int]: ENV_RECALL_MAX_TOKENS = "HINDSIGHT_API_RECALL_MAX_TOKENS" ENV_RECALL_CHUNKS_MAX_TOKENS = "HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS" +# Mental model refresh +ENV_MENTAL_MODEL_DELTA_FAST_PATH = "HINDSIGHT_API_MENTAL_MODEL_DELTA_FAST_PATH" + # Recall pipeline stages. Each arm of recall costs latency, and a bank whose # content has no temporal or relational structure pays for stages it cannot use # (e.g. a chunk-extraction bank used as plain retrieval). These switch the @@ -1336,6 +1339,12 @@ def _parse_strategy_boosts(raw: str | None) -> dict[str, str]: DEFAULT_RECALL_MAX_TOKENS = 2048 # Token budget for facts returned by internal recall DEFAULT_RECALL_CHUNKS_MAX_TOKENS = 1000 # Token budget for raw chunks returned by internal recall +# Mental model refresh: run the deterministic delta fast path before the agentic +# reflect loop. On by default — the fast path preserves every outcome and hands +# back to the loop on any doubt, so the loop still produces the result whenever +# a surgical edit is not obviously safe. Set false to always run the loop. +DEFAULT_MENTAL_MODEL_DELTA_FAST_PATH = True + # Recall pipeline stages — all on by default, so recall behaviour is unchanged # unless a bank opts out. DEFAULT_ENABLE_TEMPORAL_RETRIEVAL = True # Temporal retrieval arm + the date-aware query analysis feeding it @@ -2472,6 +2481,9 @@ class HindsightConfig: recall_max_tokens: int recall_chunks_max_tokens: int + # Mental model refresh: deterministic delta fast path before the agentic loop + mental_model_delta_fast_path: bool + # Recall budget mapping: how the Budget enum (LOW/MID/HIGH) maps to thinking_budget integer. # function="fixed": use the recall_budget_fixed_* values directly (legacy behavior). # function="adaptive": compute round(max_tokens * recall_budget_adaptive_*), @@ -2723,6 +2735,8 @@ class HindsightConfig: "recall_include_chunks", "recall_max_tokens", "recall_chunks_max_tokens", + # Mental model refresh (per-model override lives on trigger.delta_fast_path) + "mental_model_delta_fast_path", # Recall budget mapping (Budget enum -> thinking_budget integer) "recall_budget_function", "recall_budget_fixed_low", @@ -3814,6 +3828,10 @@ def from_env(cls) -> "HindsightConfig": recall_chunks_max_tokens=int( os.getenv(ENV_RECALL_CHUNKS_MAX_TOKENS, str(DEFAULT_RECALL_CHUNKS_MAX_TOKENS)) ), + mental_model_delta_fast_path=os.getenv( + ENV_MENTAL_MODEL_DELTA_FAST_PATH, str(DEFAULT_MENTAL_MODEL_DELTA_FAST_PATH) + ).lower() + in ("true", "1", "yes"), recall_budget_function=_validate_recall_budget_function( os.getenv(ENV_RECALL_BUDGET_FUNCTION, DEFAULT_RECALL_BUDGET_FUNCTION) ), diff --git a/hindsight-api-slim/hindsight_api/engine/memory_engine.py b/hindsight-api-slim/hindsight_api/engine/memory_engine.py index 1a1b46fd34..575c79f041 100644 --- a/hindsight-api-slim/hindsight_api/engine/memory_engine.py +++ b/hindsight-api-slim/hindsight_api/engine/memory_engine.py @@ -421,9 +421,11 @@ def validate_sql_schema(sql: str) -> None: from .entity_resolver import EntityResolver from .llm_wrapper import LLMConfig, requires_api_key, sanitize_llm_output, sanitize_text from .mental_model_refresh import ( + FastPathFallbackReason, MentalModelDeltaOperations, MentalModelDryRunRefreshResult, MentalModelFactCounts, + MentalModelFastPathTier, MentalModelRefreshScope, MentalModelRefreshTrace, MentalModelRefreshWindow, @@ -1209,6 +1211,11 @@ def _tally(fact_type: str, item_id: Any) -> None: #: trace: it explains results that look older than the window should allow. _WINDOW_BOUNDED_TOOLS = frozenset({"recall", "search_observations"}) +#: Token budget the delta fast path gives its observations fetch. Mirrors the +#: default the reflect agent's ``search_observations`` tool is bound with, so the +#: fast path reads the same slice of the window the loop's first call would. +_FAST_PATH_OBSERVATIONS_MAX_TOKENS = 5000 + def _summarize_refresh_tool_calls( tool_trace: list[ToolCallTrace], created_after: datetime | None = None @@ -1274,6 +1281,8 @@ class _MentalModelRefreshRun: source_query: str processed_watermark: datetime | None outcome: RefreshOutcome + fast_path: MentalModelFastPathTier | None = None + fast_path_fallback_reason: FastPathFallbackReason | None = None tool_calls: list[MentalModelTraceToolCall] = field(default_factory=list) llm_calls: list[LLMCallTrace] = field(default_factory=list) usage: TokenUsage = field(default_factory=TokenUsage) @@ -1298,6 +1307,8 @@ def to_trace(self, *, include_tool_outputs: bool = False) -> MentalModelRefreshT effective_mode=self.effective_mode, mode_fallback_reason=self.mode_fallback_reason, outcome=self.outcome, + fast_path=self.fast_path, + fast_path_fallback_reason=self.fast_path_fallback_reason, tool_calls=tool_calls, llm_calls=self.llm_calls, delta_operations=self.delta_operations, @@ -1307,6 +1318,242 @@ def to_trace(self, *, include_tool_outputs: bool = False) -> MentalModelRefreshT ) +@dataclass(frozen=True) +class _MentalModelRefreshContext: + """What a refresh resolved before it branched on mode or outcome. + + Every branch of ``_execute_mental_model_refresh`` reports the same identity, + scope and window; only the document it produced differs. Grouping them here + is what lets ``_build_refresh_run`` be a free function callable from *before* + the reflect loop runs — the property the deterministic delta fast path needs + in order to report through the same path as the agentic one. + """ + + mental_model_id: str + name: str + requested_mode: RefreshMode + scope: MentalModelRefreshScope + window: MentalModelRefreshWindow + current_content: str + source_query: str + processed_watermark: datetime | None + started: float + + +@dataclass(frozen=True) +class _MentalModelRefreshEvidence: + """What produced a refresh's candidate document, whichever path produced it. + + The agentic loop fills this from its ``ReflectResult``; the delta fast path + fills it from its own typed retrieval plus (on tier 1) its single delta call. + The two fields later stages still mutate — ``reflect_response`` and + ``warnings`` — are deliberately NOT carried here, so nothing holds a snapshot + that keeps changing under it; they are passed per branch instead. + """ + + candidate_content: str + facts: MentalModelFactCounts + tool_calls: list[MentalModelTraceToolCall] = field(default_factory=list) + llm_calls: list[LLMCallTrace] = field(default_factory=list) + usage: TokenUsage = field(default_factory=TokenUsage) + + +def _build_refresh_run( + ctx: _MentalModelRefreshContext, + evidence: _MentalModelRefreshEvidence, + *, + effective_mode: RefreshMode, + mode_fallback_reason: ModeFallbackReason | None, + final_content: str, + final_structured: StructuredDocument | None, + delta_operations: MentalModelDeltaOperations | None, + reflect_response: dict[str, Any], + outcome: RefreshOutcome, + warnings: list[str], + fast_path: MentalModelFastPathTier | None = None, + fast_path_fallback_reason: FastPathFallbackReason | None = None, +) -> _MentalModelRefreshRun: + """Assemble the run that one refresh branch produced. + + This was a closure inside ``_execute_mental_model_refresh`` that captured the + post-reflect locals. It is a free function now because the delta fast path + finishes *before* those locals exist, and a fast-path refresh that reported + through a different assembler than the agentic one would drift from it. + """ + return _MentalModelRefreshRun( + mental_model_id=ctx.mental_model_id, + name=ctx.name, + requested_mode=ctx.requested_mode, + effective_mode=effective_mode, + mode_fallback_reason=mode_fallback_reason, + scope=ctx.scope, + window=ctx.window, + facts=evidence.facts, + current_content=ctx.current_content, + candidate_content=evidence.candidate_content, + final_content=final_content, + final_structured=final_structured, + delta_operations=delta_operations, + reflect_response=reflect_response, + source_query=ctx.source_query, + processed_watermark=ctx.processed_watermark, + outcome=outcome, + fast_path=fast_path, + fast_path_fallback_reason=fast_path_fallback_reason, + tool_calls=evidence.tool_calls, + llm_calls=evidence.llm_calls, + usage=evidence.usage, + duration_ms=int((time.time() - ctx.started) * 1000), + warnings=warnings, + ) + + +@dataclass(frozen=True) +class _DeltaFastPathSuccess: + """A delta refresh the deterministic fast path completed on its own. + + Tier 0 read the window, found nothing new, and preserved the document without + an LLM call. Tier 1 turned the window's facts into edit operations with one + call. Either way the agentic reflect loop never ran, so this carries the same + evidence the loop would have produced — that is what lets both routes report + through ``_build_refresh_run``. + """ + + tier: MentalModelFastPathTier + evidence: "_MentalModelRefreshEvidence" + based_on: dict[str, list[dict[str, Any]]] + final_content: str + final_structured: StructuredDocument | None + delta_operations: MentalModelDeltaOperations | None + outcome: RefreshOutcome + delta_applied: bool + delta_skipped_reason: str | None = None + warnings: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class _DeltaFastPathFallback: + """The fast path ran and handed the refresh back to the agentic reflect loop. + + Recorded rather than swallowed so the ledger explains every loop run: a delta + refresh that still cost a full agentic pass either was never eligible for the + fast path or hit one of these. + """ + + reason: FastPathFallbackReason + + +#: ``None`` from ``_try_delta_fast_path`` means the fast path never ran at all +#: (switched off, or the refresh is not an eligible delta); a fallback means it +#: ran and declined. The distinction is what ``fast_path_fallback_reason`` reports. +_DeltaFastPathResult = _DeltaFastPathSuccess | _DeltaFastPathFallback + + +def _load_delta_baseline( + mental_model_id: str, + current_content: str, + stored_structured_content: dict[str, Any] | None, +) -> StructuredDocument | None: + """The structured document a delta refresh edits, or None when there isn't one. + + Uses the previously stored structured doc when available; otherwise parses the + existing markdown, so the very first delta refresh can operate without waiting + for a full rebuild. + + A stored doc that fails validation (hand-edited JSON, a shape from an older + schema) is NOT fatal: the markdown in ``content`` is the same document and + ``parse_markdown`` is lenient, so re-deriving the baseline from it keeps the + delta path alive and rebuilds the structured doc as a side effect. Giving up + there would refuse every subsequent refresh (nothing else repairs the column) + over a baseline we can reconstruct. + + Shared by the deterministic fast path and the agentic path, which must edit + the same baseline or the fast path would not be predicting the same refresh. + """ + from .reflect.structured_doc import parse_markdown + + if stored_structured_content is not None: + try: + return StructuredDocument.model_validate(stored_structured_content) + except Exception as exc: + logger.warning( + f"[MENTAL_MODELS] Stored structured doc for {mental_model_id} is unusable " + f"({exc}); re-deriving the delta baseline from the stored markdown" + ) + try: + return parse_markdown(current_content) + except Exception as exc: + logger.warning( + f"[MENTAL_MODELS] Could not load structured doc for {mental_model_id} " + f"({exc}); delta has no baseline to edit" + ) + return None + + +def _serialize_window_fact(fact: dict[str, Any], fact_type: str) -> dict[str, Any]: + """One retrieved fact in the shape the structured-delta prompt consumes. + + Deliberately the same four fields the agentic path serializes out of + ``reflect_result.based_on`` (id / text / type / context) and the same three + ``build_structured_delta_prompt`` renders — a fast-path prompt carrying extra + fields would not be the prompt the delta system prompt was written for. + """ + return { + "id": str(fact.get("id", "")), + "text": fact.get("text", ""), + "type": fact_type, + "context": fact.get("context"), + } + + +def _retrieval_warnings(facts: MentalModelFactCounts) -> list[str]: + """Plain-language warnings about a refresh's retrieval, if it looks unhealthy. + + Shared by the agentic and fast paths so the same retrieval state always reads + the same way — the fast path's tier 0 IS the "retrieval returned nothing" case, + and a preview that described it differently from the loop would be misleading. + """ + warnings: list[str] = [] + retrieved_total = sum(facts.retrieved.values()) + used_total = sum(facts.used.values()) + if retrieved_total == 0: + warnings.append( + "Retrieval returned no facts at all. Check the resolved scope and the time window — " + "in delta mode nothing created after the last refresh is in range." + ) + elif used_total == 0: + warnings.append( + f"Retrieval returned {retrieved_total} fact(s) but the reflect agent used none of them, " + "so the document was written from an empty evidence set. The source query may not match " + "what the retrieved memories are about." + ) + return warnings + + +def _accumulate_delta_based_on( + based_on: dict[str, list[dict[str, Any]]], + previous_reflect_response: dict[str, Any] | None, +) -> None: + """Fold a delta refresh's prior evidence into this run's, in place. + + In delta mode ``based_on`` must accumulate: the mental model is grounded on + ALL facts ever used, not just the latest window's. Carried facts are + deduplicated by id against the new ones, newest first. + + Shared by both delta routes. A fast-path refresh that skipped this would keep + only the handful of facts in its window and silently drop the document's whole + provenance on the first tier-1 write. + """ + prev_based_on = (previous_reflect_response or {}).get("based_on") or {} + for ftype, prev_facts in prev_based_on.items(): + if not isinstance(prev_facts, list): + continue + new_ids = {f["id"] for f in based_on.get(ftype, [])} + carried = [f for f in prev_facts if isinstance(f, dict) and f.get("id") not in new_ids] + if carried: + based_on.setdefault(ftype, []).extend(carried) + + @dataclass class ResolvedDispositionMission: """Disposition + mission after overlaying resolved bank config on the legacy columns.""" @@ -3235,6 +3482,13 @@ async def _write_refresh_outcome_metadata(self, operation_id: str | None, refres based_on_counts={fact_type: len(facts or []) for fact_type, facts in based_on.items()}, delta_ops_applied=len(reflect_response.get("delta_operations_applied") or []), delta_ops_skipped=len(reflect_response.get("delta_operations_skipped") or []), + # reflect_response carries the tier as `fast_path`: "tier0"/"tier1" from the + # fast path, and None from the agentic loop. Normalised to "tier2" here so + # the persisted value names the tier that ran rather than the absence of a + # flag -- a null in result_metadata would be ambiguous between "agentic" and + # "written by a build that predates this field". + serving_tier=reflect_response.get("fast_path") or "tier2", + fast_path_fallback_reason=reflect_response.get("fast_path_fallback_reason"), ) try: backend = await self._get_backend() @@ -12650,6 +12904,298 @@ async def _mental_model_processed_watermark( return max(newest_in_scope, current_last_refreshed_at) return newest_in_scope + async def _try_delta_fast_path( + self, + ctx: _MentalModelRefreshContext, + *, + bank_id: str, + request_context: "RequestContext", + resolved_config: "HindsightConfig", + tag_filtering: RefreshTagFiltering, + fact_types: list[str] | None, + stored_structured_content: dict[str, Any] | None, + stored_max_tokens: int | None, + recall_max_tokens_override: int | None, + operation_label: str, + ) -> _DeltaFastPathResult: + """Run a delta refresh without the agentic loop, or decline and say why. + + Delta mode's premise is incremental work, but every delta refresh still ran + the full reflect loop first: several LLM calls, each resending the whole + accumulated conversation, to produce a handful of edit operations — and on + a large share of refreshes, none at all. The pieces needed to skip it were + already here, just sequenced behind the loop instead of in front of it. + + Two tiers, both bounded: + + - **Tier 0** reads the delta window with the same two typed retrieval calls + the loop's tools make. No facts in the window means nothing to integrate, + so the document is preserved and the watermark advances — the same + outcome the loop reaches after paying for it. Zero LLM calls. + - **Tier 1** hands those facts and the current document to the existing + structured-delta prompt, in one call, and applies what comes back. + + Anything less than clearly safe hands back to the loop instead: no baseline + to edit, the model saying it needs more context, a call or parse that + failed, or operations that all bounced. The loop then produces the result + exactly as it does today, so this can change what a refresh COSTS but not + what outcomes are reachable. + + By construction it can only apply operations to the existing document or + preserve it — it never writes a synthesised candidate as the whole + document, so the narrow-candidate and empty-answer failure classes the + agentic path guards against cannot arise here. + """ + from .reflect.delta_ops import ( + DeltaAllOpsInvalidError, + apply_operations, + parse_delta_operation_list, + serialize_document_for_delta_prompt, + ) + from .reflect.prompts import ( + STRUCTURED_DELTA_FAST_PATH_SYSTEM_PROMPT, + build_structured_delta_prompt, + ) + from .reflect.structured_doc import render_document + + current_doc = _load_delta_baseline(ctx.mental_model_id, ctx.current_content, stored_structured_content) + if current_doc is None: + return _DeltaFastPathFallback(reason="no_delta_baseline") + + # Mirror the loop's tool wiring rather than flattening it into one call: + # recall covers world/experience under the recall_max_tokens budget, and + # search_observations covers observations under the tool-call budget. Both + # wrappers mark the request internal, so these are not double-billed on top + # of the refresh operation itself. + include_observations = fact_types is None or "observation" in fact_types + recall_fact_types = [ft for ft in (fact_types or ["world", "experience"]) if ft in ("world", "experience")] + effective_recall_max_tokens = ( + recall_max_tokens_override if recall_max_tokens_override is not None else resolved_config.recall_max_tokens + ) + + tool_trace: list[ToolCallTrace] = [] + based_on: dict[str, list[dict[str, Any]]] = {} + created_after = ctx.window.created_after + created_before = ctx.window.created_before + + if recall_fact_types: + started_call = time.time() + # Chunks are raw source text for a synthesis step this path does not + # have: only id/text/type/context reach the delta prompt, so fetching + # them would cost a lookup whose result is discarded. + recall_out = await tool_recall( + self, + bank_id, + ctx.source_query, + request_context, + max_tokens=effective_recall_max_tokens, + tags=tag_filtering.tags, + tags_match=tag_filtering.tags_match, + tag_groups=tag_filtering.tag_groups, + fact_types=recall_fact_types if fact_types is not None else None, + include_chunks=False, + created_after=created_after, + created_before=created_before, + ) + tool_trace.append( + ToolCallTrace( + tool="recall", + input={"query": ctx.source_query, "max_tokens": effective_recall_max_tokens}, + output=recall_out, + duration_ms=int((time.time() - started_call) * 1000), + ) + ) + for memory in recall_out.get("memories") or []: + fact_type = memory.get("fact_type") or "world" + based_on.setdefault(fact_type, []).append(_serialize_window_fact(memory, fact_type)) + + if include_observations: + started_call = time.time() + # last_consolidated_at / pending_consolidation only shape the freshness + # fields in the response, which the agent reads and this path does not; + # source facts are off for the same reason chunks are. + observations_out = await tool_search_observations( + self, + bank_id, + ctx.source_query, + request_context, + max_tokens=_FAST_PATH_OBSERVATIONS_MAX_TOKENS, + tags=tag_filtering.tags, + tags_match=tag_filtering.tags_match, + tag_groups=tag_filtering.tag_groups, + source_facts_max_tokens=-1, + created_after=created_after, + created_before=created_before, + ) + tool_trace.append( + ToolCallTrace( + tool="search_observations", + input={"query": ctx.source_query, "max_tokens": _FAST_PATH_OBSERVATIONS_MAX_TOKENS}, + output=observations_out, + duration_ms=int((time.time() - started_call) * 1000), + ) + ) + for observation in observations_out.get("observations") or []: + based_on.setdefault("observation", []).append(_serialize_window_fact(observation, "observation")) + + supporting_facts = [fact for facts in based_on.values() for fact in facts] + # Everything retrieved is sent to the delta prompt — there is no agent in + # between to declare a subset relevant — so used mirrors retrieved here. + facts = MentalModelFactCounts( + retrieved=_count_retrieved_facts(tool_trace), + used={fact_type: len(serialized) for fact_type, serialized in based_on.items() if serialized}, + ) + tool_calls = _summarize_refresh_tool_calls(tool_trace, created_after) + + if not supporting_facts: + logger.info( + f"[MENTAL_MODELS] Delta fast path (tier 0) for {ctx.mental_model_id}: " + "no new facts in the window, preserving content without an LLM call" + ) + return _DeltaFastPathSuccess( + tier="tier0", + evidence=_MentalModelRefreshEvidence( + candidate_content=ctx.current_content, + facts=facts, + tool_calls=tool_calls, + ), + based_on={}, + final_content=ctx.current_content, + final_structured=None, + delta_operations=None, + outcome="content_preserved_no_new_facts", + delta_applied=False, + delta_skipped_reason="no_new_facts", + warnings=_retrieval_warnings(facts), + ) + + # Same budget arithmetic as the agentic path's delta call: op JSON is + # denser than the rendered markdown, so allow 1.5x the document cap. + doc_max_tokens = stored_max_tokens or 2048 + delta_max_tokens = max(2048, int(doc_max_tokens * 1.5)) + user_prompt = build_structured_delta_prompt( + # Annotated with each block's own index, not a bare model dump — + # see serialize_document_for_delta_prompt's docstring for why a + # compact, unannotated dump makes the model silently count array + # elements to know a block's index, which is the root cause the + # anchor/index fix in delta_ops.py exists to remove. + current_document_json=serialize_document_for_delta_prompt(current_doc), + # There is no synthesis on this path — that is the call being skipped. + # The fast-path system prompt says so and tells the model to ask for + # the agentic pass rather than guess when the facts alone fall short. + candidate_markdown="", + supporting_facts=supporting_facts, + source_query=ctx.source_query, + max_output_tokens=delta_max_tokens, + ) + started_call = time.time() + try: + raw, usage = await self._reflect_llm_config.with_config( + resolved_config, bank_id=bank_id, operation=operation_label + ).call( + messages=[ + {"role": "system", "content": STRUCTURED_DELTA_FAST_PATH_SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + max_completion_tokens=delta_max_tokens, + temperature=get_config().llm_temperature_consolidation, + scope="mental_model_delta_ops", + return_usage=True, + ) + op_list = parse_delta_operation_list(raw) + except DeltaAllOpsInvalidError as exc: + logger.warning( + f"[MENTAL_MODELS] Delta fast path for {ctx.mental_model_id}: every operation failed " + f"validation ({exc}); handing back to the reflect loop" + ) + return _DeltaFastPathFallback(reason="delta_ops_invalid") + except Exception as exc: + logger.warning( + f"[MENTAL_MODELS] Delta fast path for {ctx.mental_model_id} could not produce " + f"operations ({exc}); handing back to the reflect loop" + ) + return _DeltaFastPathFallback(reason="delta_ops_failed") + + llm_calls = [LLMCallTrace(scope="mental_model_delta_ops", duration_ms=int((time.time() - started_call) * 1000))] + + if op_list.needs_full_context: + logger.info( + f"[MENTAL_MODELS] Delta fast path for {ctx.mental_model_id}: the model asked for full " + "context; handing back to the reflect loop" + ) + return _DeltaFastPathFallback(reason="needs_full_context") + + apply_outcome = apply_operations(current_doc, op_list.operations) + if op_list.operations and not apply_outcome.applied: + # Every op was rejected, so the document is unchanged while this + # window's facts never landed. The loop gets a turn rather than the + # refresh being persisted as if it had integrated them. + logger.warning( + f"[MENTAL_MODELS] Delta fast path for {ctx.mental_model_id}: all " + f"{len(apply_outcome.skipped)} op(s) were skipped; handing back to the reflect loop" + ) + return _DeltaFastPathFallback(reason="delta_ops_all_skipped") + + final_content = render_document(apply_outcome.document) + warnings = _retrieval_warnings(facts) + if apply_outcome.skipped: + warnings.append( + f"{len(apply_outcome.skipped)} of {len(op_list.operations)} delta operation(s) " + "were rejected and their content did not reach the document. See the skipped " + "operations for the reason each was dropped." + ) + if not final_content.strip(): + # Reachable only by operations that empty the document (removing every + # section). Rare, but the invariant that a refresh never overwrites a + # working document with nothing is not one to special-case away. + warnings.append( + "The refresh produced empty content, which usually means an upstream LLM failure. " + "A real refresh would preserve the existing content and fail." + ) + return _DeltaFastPathSuccess( + tier="tier1", + evidence=_MentalModelRefreshEvidence( + candidate_content=final_content, + facts=facts, + tool_calls=tool_calls, + llm_calls=llm_calls, + usage=usage, + ), + based_on=based_on, + final_content=final_content, + final_structured=None, + delta_operations=MentalModelDeltaOperations( + applied=apply_outcome.applied, skipped=apply_outcome.skipped + ), + outcome="refresh_failed_empty_candidate", + delta_applied=False, + warnings=warnings, + ) + + logger.info( + f"[MENTAL_MODELS] Delta fast path (tier 1) for {ctx.mental_model_id}: applied " + f"{len(apply_outcome.applied)} op(s), skipped {len(apply_outcome.skipped)}, in one LLM call" + ) + return _DeltaFastPathSuccess( + tier="tier1", + evidence=_MentalModelRefreshEvidence( + # No reflect synthesis exists on this path, so the "candidate" a + # preview reports is the document the operations produced. + candidate_content=final_content, + facts=facts, + tool_calls=tool_calls, + llm_calls=llm_calls, + usage=usage, + ), + based_on=based_on, + final_content=final_content, + final_structured=apply_outcome.document, + delta_operations=MentalModelDeltaOperations(applied=apply_outcome.applied, skipped=apply_outcome.skipped), + outcome="content_written", + delta_applied=True, + warnings=warnings, + ) + async def _execute_mental_model_refresh( self, bank_id: str, @@ -12822,6 +13368,74 @@ async def _execute_mental_model_refresh( created_before=refresh_cutoff, watermark=processed_watermark, ) + ctx = _MentalModelRefreshContext( + mental_model_id=mental_model_id, + name=mm_name, + requested_mode=requested_mode, + scope=scope, + window=window, + current_content=current_content, + source_query=source_query, + processed_watermark=processed_watermark, + started=started, + ) + + # Deterministic delta fast path, ahead of the agentic loop. Only delta + # refreshes that survived the mode decision above are eligible: full mode + # regenerates the whole document from the unbounded window, which is not + # something edit operations can express. Resolving config here also gives + # the delta LLM call below its bank attribution. + fast_path_fallback_reason: FastPathFallbackReason | None = None + resolved_config: HindsightConfig | None = None + if use_delta: + resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context) + trigger_fast_path = trigger_data.get("delta_fast_path") + fast_path_enabled = ( + resolved_config.mental_model_delta_fast_path if trigger_fast_path is None else bool(trigger_fast_path) + ) + if fast_path_enabled: + fast_path = await self._try_delta_fast_path( + ctx, + bank_id=bank_id, + request_context=request_context, + resolved_config=resolved_config, + tag_filtering=tag_filtering, + fact_types=fact_types, + stored_structured_content=stored_structured_content, + stored_max_tokens=stored_max_tokens, + recall_max_tokens_override=recall_max_tokens_override, + operation_label=operation_label, + ) + if isinstance(fast_path, _DeltaFastPathSuccess): + based_on = fast_path.based_on + _accumulate_delta_based_on(based_on, mental_model.get("reflect_response")) + reflect_response_payload = { + "text": fast_path.evidence.candidate_content, + "based_on": based_on, + "mental_models": [], + "delta_applied": fast_path.delta_applied, + "fast_path": fast_path.tier, + "fast_path_fallback_reason": None, + } + if fast_path.delta_skipped_reason is not None: + reflect_response_payload["delta_skipped_reason"] = fast_path.delta_skipped_reason + if fast_path.delta_operations is not None: + reflect_response_payload["delta_operations_applied"] = fast_path.delta_operations.applied + reflect_response_payload["delta_operations_skipped"] = fast_path.delta_operations.skipped + return _build_refresh_run( + ctx, + fast_path.evidence, + effective_mode="delta", + mode_fallback_reason=None, + final_content=fast_path.final_content, + final_structured=fast_path.final_structured, + delta_operations=fast_path.delta_operations, + reflect_response=reflect_response_payload, + outcome=fast_path.outcome, + warnings=fast_path.warnings, + fast_path=fast_path.tier, + ) + fast_path_fallback_reason = fast_path.reason reflect_result = await self.reflect_async(**reflect_kwargs) @@ -12875,71 +13489,27 @@ async def _execute_mental_model_refresh( # grounded on ALL facts ever used, not just the latest delta's new # ones. Merge previous based_on with current, deduplicating by id. if use_delta: - prev_rr = mental_model.get("reflect_response") or {} - prev_based_on = prev_rr.get("based_on") or {} - for ftype, prev_facts in prev_based_on.items(): - if not isinstance(prev_facts, list): - continue - new_ids = {f["id"] for f in based_on_serialized_payload.get(ftype, [])} - carried = [f for f in prev_facts if isinstance(f, dict) and f.get("id") not in new_ids] - if carried: - based_on_serialized_payload.setdefault(ftype, []).extend(carried) + _accumulate_delta_based_on(based_on_serialized_payload, mental_model.get("reflect_response")) reflect_response_payload: dict[str, Any] = { "text": reflect_result.text, "based_on": based_on_serialized_payload, "mental_models": [], # Mental models are included in based_on["mental-models"] + # Null tier: this refresh came from the agentic loop. The reason says + # whether the fast path declined it, or never looked at it. + "fast_path": None, + "fast_path_fallback_reason": fast_path_fallback_reason, } - warnings: list[str] = [] - retrieved_total = sum(facts.retrieved.values()) - used_total = sum(facts.used.values()) - if retrieved_total == 0: - warnings.append( - "Retrieval returned no facts at all. Check the resolved scope and the time window — " - "in delta mode nothing created after the last refresh is in range." - ) - elif used_total == 0: - warnings.append( - f"Retrieval returned {retrieved_total} fact(s) but the reflect agent used none of them, " - "so the document was written from an empty evidence set. The source query may not match " - "what the retrieved memories are about." - ) + warnings = _retrieval_warnings(facts) - def _finish( - *, - effective_mode: RefreshMode, - mode_fallback_reason: ModeFallbackReason | None, - final_content: str, - final_structured: StructuredDocument | None, - delta_operations: MentalModelDeltaOperations | None, - outcome: RefreshOutcome, - ) -> _MentalModelRefreshRun: - """Close over everything the pipeline resolved before it branched.""" - return _MentalModelRefreshRun( - mental_model_id=mental_model_id, - name=mm_name, - requested_mode=requested_mode, - effective_mode=effective_mode, - mode_fallback_reason=mode_fallback_reason, - scope=scope, - window=window, - facts=facts, - current_content=current_content, - candidate_content=reflect_result.text, - final_content=final_content, - final_structured=final_structured, - delta_operations=delta_operations, - reflect_response=reflect_response_payload, - source_query=source_query, - processed_watermark=processed_watermark, - outcome=outcome, - tool_calls=_summarize_refresh_tool_calls(reflect_result.tool_trace, created_after), - llm_calls=list(reflect_result.llm_trace), - usage=reflect_result.usage or TokenUsage(), - duration_ms=int((time.time() - started) * 1000), - warnings=warnings, - ) + evidence = _MentalModelRefreshEvidence( + candidate_content=reflect_result.text, + facts=facts, + tool_calls=_summarize_refresh_tool_calls(reflect_result.tool_trace, created_after), + llm_calls=list(reflect_result.llm_trace), + usage=reflect_result.usage or TokenUsage(), + ) # Delta-mode path: emit structured operations against the existing # structured doc, apply them, then re-render to markdown. Sections @@ -12949,6 +13519,7 @@ def _finish( from .reflect.delta_ops import ( apply_operations, parse_delta_operation_list, + serialize_document_for_delta_prompt, ) from .reflect.prompts import ( STRUCTURED_DELTA_SYSTEM_PROMPT, @@ -12965,34 +13536,9 @@ def _finish( delta_operations: MentalModelDeltaOperations | None = None if use_delta: - # Use the previously stored structured doc when available; otherwise - # parse the existing markdown so the very first delta refresh can - # still operate without waiting for a full rebuild. - # - # A stored doc that fails validation (hand-edited JSON, a shape from an - # older schema) is NOT fatal: the markdown in ``content`` is the same - # document and ``parse_markdown`` is lenient, so re-deriving the baseline - # from it keeps the delta path alive and rebuilds the structured doc as a - # side effect. Giving up here would refuse every subsequent refresh - # (nothing else repairs the column) over a baseline we can reconstruct. - current_doc: StructuredDocument | None = None - if stored_structured_content is not None: - try: - current_doc = StructuredDocument.model_validate(stored_structured_content) - except Exception as exc: - logger.warning( - f"[MENTAL_MODELS] Stored structured doc for {mental_model_id} is unusable " - f"({exc}); re-deriving the delta baseline from the stored markdown" - ) + current_doc = _load_delta_baseline(mental_model_id, current_content, stored_structured_content) if current_doc is None: - try: - current_doc = parse_markdown(current_content) - except Exception as exc: - logger.warning( - f"[MENTAL_MODELS] Could not load structured doc for {mental_model_id} " - f"({exc}); delta has no baseline to edit" - ) - mode_fallback_reason = "structured_doc_unreadable" + mode_fallback_reason = "structured_doc_unreadable" if current_doc is not None: supporting_facts = delta_supporting_facts @@ -13002,13 +13548,18 @@ def _finish( if not supporting_facts: reflect_response_payload["delta_applied"] = False reflect_response_payload["delta_skipped_reason"] = "no_new_facts" - return _finish( + return _build_refresh_run( + ctx, + evidence, effective_mode="delta", mode_fallback_reason=None, final_content=current_content, final_structured=None, delta_operations=None, + reflect_response=reflect_response_payload, outcome="content_preserved_no_new_facts", + warnings=warnings, + fast_path_fallback_reason=fast_path_fallback_reason, ) # Op JSON is denser than the rendered markdown — each op @@ -13020,7 +13571,9 @@ def _finish( doc_max_tokens = stored_max_tokens or 2048 delta_max_tokens = max(2048, int(doc_max_tokens * 1.5)) user_prompt = build_structured_delta_prompt( - current_document_json=current_doc.model_dump_json(), + # Annotated with each block's own index — see + # serialize_document_for_delta_prompt's docstring. + current_document_json=serialize_document_for_delta_prompt(current_doc), candidate_markdown=reflect_result.text, supporting_facts=supporting_facts, source_query=source_query, @@ -13032,7 +13585,17 @@ def _finish( # provider — Gemini in particular rejects ``oneOf`` / # ``discriminator``. We parse + validate the JSON ourselves # so the same prompt works against any LLM. - raw = await self._reflect_llm_config.call( + # + # Bound through with_config so the request logs under this + # refresh's operation. Called bare it recorded a blank + # operation — reflect's trace context has already been reset + # by the time this runs — which made delta-ops calls + # impossible to attribute or count in llm_requests. + # resolved_config is not None here: use_delta gates both. + assert resolved_config is not None + raw = await self._reflect_llm_config.with_config( + resolved_config, bank_id=bank_id, operation=operation_label + ).call( messages=[ {"role": "system", "content": STRUCTURED_DELTA_SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}, @@ -13115,13 +13678,18 @@ def _finish( "The refresh produced empty content, which usually means an upstream LLM failure. " "A real refresh would preserve the existing content and fail." ) - return _finish( + return _build_refresh_run( + ctx, + evidence, effective_mode=effective_mode, mode_fallback_reason=mode_fallback_reason, final_content=final_content, final_structured=None, delta_operations=delta_operations, + reflect_response=reflect_response_payload, outcome="refresh_failed_empty_candidate", + warnings=warnings, + fast_path_fallback_reason=fast_path_fallback_reason, ) # Refuse to write a delta-window candidate as the whole document (#3112). @@ -13136,13 +13704,18 @@ def _finish( # this correct anyway, because a candidate read over full history IS a document # and writing it is a legitimate full regeneration. if use_delta and not delta_applied and created_after is not None: - return _finish( + return _build_refresh_run( + ctx, + evidence, effective_mode=effective_mode, mode_fallback_reason=mode_fallback_reason, final_content=final_content, final_structured=None, delta_operations=delta_operations, + reflect_response=reflect_response_payload, outcome="refresh_failed_delta_not_applied", + warnings=warnings, + fast_path_fallback_reason=fast_path_fallback_reason, ) # When delta is not applied (full mode, or delta fallback), parse the @@ -13157,13 +13730,18 @@ def _finish( f"for {mental_model_id} ({exc}); leaving structured_content unchanged" ) - return _finish( + return _build_refresh_run( + ctx, + evidence, effective_mode=effective_mode, mode_fallback_reason=mode_fallback_reason, final_content=final_content, final_structured=final_structured, delta_operations=delta_operations, + reflect_response=reflect_response_payload, outcome="content_written", + warnings=warnings, + fast_path_fallback_reason=fast_path_fallback_reason, ) async def refresh_mental_model( @@ -13399,6 +13977,8 @@ async def dry_run_refresh_mental_model( effective_mode=run.effective_mode, mode_fallback_reason=run.mode_fallback_reason, outcome=run.outcome, + fast_path=run.fast_path, + fast_path_fallback_reason=run.fast_path_fallback_reason, would_persist=run.outcome == "content_written", scope=run.scope, window=run.window, @@ -14671,6 +15251,14 @@ async def list_directives( scoped_clause = tags_clause.replace("AND ", "", 1) filters.append(f"((tags IS NULL OR tags = '{{}}') OR ({scoped_clause}))") params.extend(tags_params) + if tag_groups: + # Directives carry no entity postings, so an entity leaf is + # unanswerable here and reads permissively; only the surviving + # tag constraints scope the directive set. memory_units queries + # keep their entity leaves. + from .search.tags import strip_entity_leaves + + tag_groups = strip_entity_leaves(tag_groups) if tag_groups: groups_clause, groups_params, param_idx = build_tag_groups_where_clause( tag_groups, param_offset=param_idx diff --git a/hindsight-api-slim/hindsight_api/engine/mental_model_refresh.py b/hindsight-api-slim/hindsight_api/engine/mental_model_refresh.py index 1356b2ff39..c52f216044 100644 --- a/hindsight-api-slim/hindsight_api/engine/mental_model_refresh.py +++ b/hindsight-api-slim/hindsight_api/engine/mental_model_refresh.py @@ -41,6 +41,24 @@ "refresh_failed_delta_not_applied", ] +#: Which tier of the deterministic delta fast path produced a refresh. ``tier0`` +#: read the window and found nothing new, so it made no LLM call at all; +#: ``tier1`` turned the window's facts into edit operations with exactly one. +#: Null means the refresh came from the agentic reflect loop, as every refresh +#: did before the fast path existed. +MentalModelFastPathTier = Literal["tier0", "tier1"] + +#: Why the fast path handed a delta refresh back to the agentic loop. Kept +#: separate from ``ModeFallbackReason`` on purpose: the mode is still delta, and +#: the outcome is whatever the loop then produced — only the route changed. +FastPathFallbackReason = Literal[ + "no_delta_baseline", + "needs_full_context", + "delta_ops_failed", + "delta_ops_invalid", + "delta_ops_all_skipped", +] + class MentalModelRefreshScope(BaseModel): """The memory scope a refresh actually resolved to. @@ -165,6 +183,17 @@ class MentalModelRefreshTrace(BaseModel): default=None, description="Why delta was requested but not applied, if that happened." ) outcome: RefreshOutcome = Field(description="What the refresh did with the document.") + fast_path: MentalModelFastPathTier | None = Field( + default=None, + description=( + "Which tier of the deterministic delta fast path produced this refresh, if any. " + "Null means the agentic reflect loop did." + ), + ) + fast_path_fallback_reason: FastPathFallbackReason | None = Field( + default=None, + description="Why the fast path handed this refresh back to the agentic loop, if that happened.", + ) tool_calls: list[MentalModelTraceToolCall] = Field( default_factory=list, description="Reflect tool calls made during the refresh." ) @@ -213,6 +242,18 @@ class MentalModelDryRunRefreshResult(BaseModel): default=None, description="Why delta was requested but not applied, if that happened." ) outcome: RefreshOutcome = Field(description="What a real refresh would do with the document.") + fast_path: MentalModelFastPathTier | None = Field( + default=None, + description=( + "Which tier of the deterministic delta fast path produced this run, if any. Null means " + "the agentic reflect loop did — either because the fast path was off, did not apply, or " + "handed back (see fast_path_fallback_reason)." + ), + ) + fast_path_fallback_reason: FastPathFallbackReason | None = Field( + default=None, + description="Why the fast path handed this run back to the agentic loop, if that happened.", + ) would_persist: bool = Field(description="Whether a real refresh would write new content.") scope: MentalModelRefreshScope = Field(description="The resolved memory scope.") window: MentalModelRefreshWindow = Field(description="The snapshot window read from.") @@ -226,7 +267,15 @@ class MentalModelDryRunRefreshResult(BaseModel): ), ) current_content: str = Field(description="The model's content as it stands now.") - candidate_content: str = Field(description="Raw reflect synthesis, before any delta operations.") + candidate_content: str = Field( + description=( + "The document the run's synthesis step produced, before any delta operations: the raw " + "reflect answer when the agentic loop ran. The delta fast path has no synthesis step, " + "so it reports what it would write instead — the current content on tier 0 (nothing " + "new was found), and the post-operation document on tier 1 (identical to " + "preview_content). Compare against preview_content to see what the delta changed." + ) + ) preview_content: str = Field( description="The content a real refresh would store: the delta-edited document, or the candidate in full mode." ) diff --git a/hindsight-api-slim/hindsight_api/engine/operation_metadata.py b/hindsight-api-slim/hindsight_api/engine/operation_metadata.py index eaf629f12e..880baf13ac 100644 --- a/hindsight-api-slim/hindsight_api/engine/operation_metadata.py +++ b/hindsight-api-slim/hindsight_api/engine/operation_metadata.py @@ -175,6 +175,21 @@ class RefreshMentalModelOutcomeMetadata: # never reached it. Both are 0 for a full-mode refresh, which emits no ops. delta_ops_applied: int = 0 delta_ops_skipped: int = 0 + # Which tier actually served this refresh: "tier0" (delta window empty, document + # preserved, no LLM call), "tier1" (one structured-delta call), or "tier2" (the + # agentic reflect loop). Without this the tier is only readable from + # ``mental_models.reflect_response.fast_path``, which holds the LATEST refresh per + # model and is overwritten by the next one -- so a two-tier system's tier + # distribution is unrecoverable the moment a model refreshes again. Operating and + # measuring a tiered system requires knowing, per operation, which tier ran. + serving_tier: str | None = None + # Set only on tier2, naming why the fast path handed back ("no_delta_baseline", + # "needs_full_context", "delta_ops_failed", "delta_ops_invalid", + # "delta_ops_all_skipped"). A tier2 rate is a number; this is the reason behind it, + # and the two failure directions the plan flags -- never falling back (dead escape + # hatch) vs always falling back (fast path not earning its place) -- are only + # distinguishable with it. + fast_path_fallback_reason: str | None = None def to_dict(self) -> dict[str, Any]: """Convert to dict for JSON serialization.""" diff --git a/hindsight-api-slim/hindsight_api/engine/reflect/delta_ops.py b/hindsight-api-slim/hindsight_api/engine/reflect/delta_ops.py index 4c864912bb..f08e4c77d7 100644 --- a/hindsight-api-slim/hindsight_api/engine/reflect/delta_ops.py +++ b/hindsight-api-slim/hindsight_api/engine/reflect/delta_ops.py @@ -4,7 +4,20 @@ each targeting an existing section (by id) or referencing a position relative to one. ``apply_operations`` validates and applies each op in turn against a copy of the document; invalid ops (unknown ``section_id``, out-of-range -``block_index``, malformed payloads) are dropped with a debug-friendly reason. +``block_index``, a mismatched block ``anchor``, malformed payloads) are +dropped with a debug-friendly reason. + +Block-targeting ops (``replace_block``, ``remove_block``, and ``insert_block`` +when its index names an existing block) also carry a content ``anchor``: a +verbatim excerpt of the block the LLM believes is at the given index. +``apply_operations`` checks the anchor against the block actually there +before mutating anything. This guards against a wrong-but-in-range index, +which is otherwise indistinguishable from a correct one — the LLM's only way +to know a block's index in the compact JSON it is shown is to count array +elements, and a miscount silently lands the op on the wrong block instead of +failing loudly. See ``serialize_document_for_delta_prompt`` for the +prompt-facing view that annotates each block with its index so the model can +read it instead of counting. Sections and blocks not mentioned by any op are physically copied through unchanged — there is no LLM-mediated re-emission of unchanged text, so prose @@ -28,6 +41,7 @@ import json import logging +import re from typing import Annotated, Any, Literal, Union from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError @@ -36,6 +50,10 @@ from .structured_doc import ( Block, + BulletListBlock, + CodeBlock, + OrderedListBlock, + ParagraphBlock, Section, StructuredDocument, make_unique_id, @@ -64,29 +82,55 @@ class InsertBlockOp(_OpBase): """Insert a new block at ``index`` in an existing section. ``index`` may equal ``len(section.blocks)`` (append) but not be greater. + + ``anchor`` names the block this insert will land before: a verbatim + excerpt of the block currently at ``index``. Required only when ``index`` + names an existing block (``index < len(section.blocks)``); at the append + position there is no block to anchor against, so ``anchor`` may be left + empty there. See ``ReplaceBlockOp`` for the matching rules and why this + exists. """ op: Literal["insert_block"] = "insert_block" section_id: str index: int = Field(ge=0) + anchor: str = "" block: Block class ReplaceBlockOp(_OpBase): - """Replace the block at ``index`` of an existing section.""" + """Replace the block at ``index`` of an existing section. + + ``anchor`` must be a verbatim excerpt (~50 chars) of the block's own + content at ``index`` — its ``text`` field, or ``items`` joined with a + space for list blocks — copied from what the model was shown at that + index. ``apply_operations`` compares it (whitespace-normalized) against + the block actually there before replacing it, and skips the op on a + mismatch or a missing anchor instead of applying it. This is the guard + against a miscounted, wrong-but-in-range ``index``: without it, a wrong + index is indistinguishable from a correct one and silently destroys the + wrong block's content. + """ op: Literal["replace_block"] = "replace_block" section_id: str index: int = Field(ge=0) + anchor: str = "" block: Block class RemoveBlockOp(_OpBase): - """Remove the block at ``index`` of an existing section.""" + """Remove the block at ``index`` of an existing section. + + See ``ReplaceBlockOp`` for the ``anchor`` field's role and matching + rules — identical here, since removal is equally destructive of the + wrong block on a miscounted index. + """ op: Literal["remove_block"] = "remove_block" section_id: str index: int = Field(ge=0) + anchor: str = "" class AddSectionOp(_OpBase): @@ -174,6 +218,12 @@ class DeltaOperationList(BaseModel): model_config = ConfigDict(extra="forbid") operations: list[Operation] = Field(default_factory=list) + #: The model's escape hatch: "the evidence I was given is not enough to edit + #: this document correctly". Only the delta fast path asks for it and only the + #: fast path reads it — there it means "hand this refresh to the agentic reflect + #: loop", which can go and retrieve more. Default False, so a model that never + #: mentions it (every caller before the fast path existed) behaves as it did. + needs_full_context: bool = False class DeltaAllOpsInvalidError(ValueError): @@ -186,11 +236,33 @@ class DeltaAllOpsInvalidError(ValueError): """ -def _finalize_operations(valid: list[Operation], skipped: list[dict[str, Any]]) -> DeltaOperationList: +def _coerce_needs_full_context(raw: Any) -> bool: + """Read the escape-hatch flag out of a raw delta payload. + + The delta call is text-mode, not schema-enforced (the discriminated-union JSON + schema is rejected by some providers — see the caller), so a model can answer + with the string ``"true"`` where a boolean was asked for. Anything else — + absent, null, false, a number, prose — reads as False: the flag only ever adds + a hand-off to the slower agentic loop, so the safe reading is to require an + explicit yes rather than to guess from a truthy value. + """ + if isinstance(raw, bool): + return raw + if isinstance(raw, str): + return raw.strip().lower() == "true" + return False + + +def _finalize_operations( + valid: list[Operation], + skipped: list[dict[str, Any]], + *, + needs_full_context: bool = False, +) -> DeltaOperationList: """Build the result, but refuse a wholesale validation failure as a silent no-op.""" if skipped and not valid: raise DeltaAllOpsInvalidError(f"all {len(skipped)} delta operation(s) failed validation") - return DeltaOperationList(operations=valid) + return DeltaOperationList(operations=valid, needs_full_context=needs_full_context) def _extract_balanced_json_object(text: str) -> str | None: @@ -223,7 +295,13 @@ def _extract_balanced_json_object(text: str) -> str | None: def parse_delta_operation_list(raw: Any) -> DeltaOperationList: - """Parse structured-delta LLM output into a validated operation list.""" + """Parse structured-delta LLM output into a validated operation list. + + ``needs_full_context`` is carried through every branch. It has to be threaded + explicitly: each branch rebuilds the list from the operations it validated, so + a flag left on the raw payload would be silently dropped and the fast path + would run edits the model had just said it could not make safely. + """ if isinstance(raw, DeltaOperationList): return raw if isinstance(raw, dict): @@ -235,7 +313,11 @@ def parse_delta_operation_list(raw: Any) -> DeltaOperationList: len(valid), len(skipped), ) - return _finalize_operations(valid, skipped) + return _finalize_operations( + valid, + skipped, + needs_full_context=_coerce_needs_full_context(raw.get("needs_full_context")), + ) text = (raw or "").strip() if not text: @@ -267,13 +349,121 @@ def parse_delta_operation_list(raw: Any) -> DeltaOperationList: len(valid), len(skipped), ) - return _finalize_operations(valid, skipped) + return _finalize_operations( + valid, + skipped, + needs_full_context=_coerce_needs_full_context(payload.get("needs_full_context")), + ) if last_error is not None: raise last_error return DeltaOperationList() +# Anchor matching ------------------------------------------------------------- +# +# Block-targeting ops are validated against the document by *index*, but an +# index alone cannot distinguish a correct one from a miscounted one that +# still happens to be in range. The anchor closes that gap: the LLM quotes a +# short excerpt of the block it believes is at the claimed index, and we +# check that excerpt against the block actually there before mutating it. + +#: ~40-60 chars is enough to disambiguate two blocks that happen to share a +#: short common opening (e.g. two bullet items both starting "The system"), +#: while staying cheap for the model to quote and cheap for the prompt to +#: carry every refresh. +_ANCHOR_EXCERPT_CHARS = 50 + +_WHITESPACE_RX = re.compile(r"\s+") + + +def _normalize_anchor_text(text: str) -> str: + """Collapse whitespace runs to a single space and strip. + + Anchors are compared after this normalization so incidental whitespace + differences (a stray double space, a line-wrapped newline inside the + model's quoted excerpt) never cause a spurious mismatch. Normalization + only touches whitespace characters, so any real content difference — + the case this exists to catch — still causes a mismatch. + """ + return _WHITESPACE_RX.sub(" ", text).strip() + + +def _block_content_text(block: Block) -> str: + """The block's own text content, independent of markdown rendering. + + This mirrors what ``serialize_document_for_delta_prompt`` shows the model + for each block — the raw ``text`` field, or ``items`` joined with a space + — rather than ``render_block``'s markdown form (bullet ``- `` prefixes, + code fences). A model quoting verbatim from what it was shown then + produces a matching anchor without needing to reconstruct markdown syntax + it was never shown as such. + """ + if isinstance(block, (ParagraphBlock, CodeBlock)): + return block.text + if isinstance(block, (BulletListBlock, OrderedListBlock)): + return " ".join(block.items) + raise TypeError(f"Unknown block type: {type(block)!r}") # pragma: no cover + + +def _anchor_matches(block: Block, anchor: str) -> bool: + """True if ``anchor`` verbatim-matches (whitespace-normalized) a prefix + of ``block``'s own content. An empty/whitespace-only anchor never + matches — see ``_check_block_anchor`` for the "missing anchor" case. + """ + normalized_anchor = _normalize_anchor_text(anchor) + if not normalized_anchor: + return False + normalized_content = _normalize_anchor_text(_block_content_text(block)) + return normalized_content.startswith(normalized_anchor) + + +def _check_block_anchor(section: Section, index: int, anchor: str) -> str | None: + """Validate a block-targeting op's anchor against the block actually at + ``index`` (the caller must range-check ``index`` first). + + Returns ``None`` when the op may proceed, or a skip reason string when it + must not. A missing/empty anchor is treated as a mismatch — fail closed — + so an op from a model (or an older caller) that never adopted the anchor + contract loses that op rather than risk it landing on the wrong block. + """ + if not anchor: + return "missing anchor" + if not _anchor_matches(section.blocks[index], anchor): + return f"anchor mismatch at index {index}" + return None + + +def serialize_document_for_delta_prompt(doc: StructuredDocument) -> str: + """Render a document to the JSON the delta-ops prompt shows the model, + with each block additionally annotated with its own 0-based ``index`` + within its section. + + Without this, the model has to silently count array elements in a + compact, unannotated JSON dump to know which index a block sits at — + exactly the failure mode that produces the wrong-but-in-range indices the + ``anchor`` fields above guard against. Annotating the index removes the + need to count at all. + + ``index`` is prompt-only. It is not, and must never become, a real field + on ``Block``: every block subtype declares ``model_config = + ConfigDict(extra="forbid")``, so nothing that parses this JSON back + through ``StructuredDocument.model_validate`` can accept it — callers + parse the model's *operations* against the real document, never this + view, back into a ``StructuredDocument``. + """ + sections_out = [ + { + "id": section.id, + "heading": section.heading, + "level": section.level, + "blocks": [{"index": i, **block.model_dump(mode="json")} for i, block in enumerate(section.blocks)], + } + for section in doc.sections + ] + return json.dumps({"version": doc.version, "sections": sections_out}) + + # Application --------------------------------------------------------------- @@ -306,8 +496,9 @@ def apply_operations( """Apply a list of operations to a document, returning a new document. The original document is never mutated. Invalid operations (unknown - section, out-of-range index, name collision when adding a section) are - skipped and recorded in ``skipped`` with a ``reason`` string. + section, out-of-range index, a mismatched or missing block anchor, name + collision when adding a section) are skipped and recorded in ``skipped`` + with a ``reason`` string. """ new_doc = doc.model_copy(deep=True) applied: list[dict[str, Any]] = [] @@ -340,6 +531,14 @@ def skip(op: Operation, reason: str) -> None: f"index out of range: {op.index} > {len(section.blocks)}", ) continue + if op.index < len(section.blocks): + # `index` names an existing block (the one this insert lands + # before); at the append position (index == len(blocks)) + # there is no block there to anchor against. + anchor_reason = _check_block_anchor(section, op.index, op.anchor) + if anchor_reason is not None: + skip(op, anchor_reason) + continue section.blocks.insert(op.index, op.block) applied.append(_op_summary(op)) continue @@ -355,6 +554,10 @@ def skip(op: Operation, reason: str) -> None: f"index out of range: {op.index} >= {len(section.blocks)}", ) continue + anchor_reason = _check_block_anchor(section, op.index, op.anchor) + if anchor_reason is not None: + skip(op, anchor_reason) + continue section.blocks[op.index] = op.block applied.append(_op_summary(op)) continue @@ -370,6 +573,10 @@ def skip(op: Operation, reason: str) -> None: f"index out of range: {op.index} >= {len(section.blocks)}", ) continue + anchor_reason = _check_block_anchor(section, op.index, op.anchor) + if anchor_reason is not None: + skip(op, anchor_reason) + continue section.blocks.pop(op.index) applied.append(_op_summary(op)) continue diff --git a/hindsight-api-slim/hindsight_api/engine/reflect/prompts.py b/hindsight-api-slim/hindsight_api/engine/reflect/prompts.py index dba0754284..4eeed742ca 100644 --- a/hindsight-api-slim/hindsight_api/engine/reflect/prompts.py +++ b/hindsight-api-slim/hindsight_api/engine/reflect/prompts.py @@ -851,7 +851,9 @@ def build_final_system_prompt( 2. CURRENT DOCUMENT (JSON) — the existing structured mental model. Each section has a stable ``id``, a ``heading``, a ``level`` (1..6), and an ordered list of ``blocks``. Blocks are typed: ``paragraph``, ``bullet_list``, - ``ordered_list``, ``code``, or ``table``. + ``ordered_list``, ``code``, or ``table``. Each block also carries its own + 0-based ``index`` within the section — READ it, do not count array elements + to infer it; a miscounted index is a silent, destructive bug. 3. NEW INFORMATION SYNTHESIS (markdown) — a synthesis showing how the new facts relate to the document's topic. Use it to understand context and relevance, but do NOT copy its formatting or wording wholesale. @@ -882,7 +884,16 @@ def build_final_system_prompt( room for an abstract restatement of the same point. - Operations target sections by ``section_id`` (use the ``id`` field of the section in CURRENT DOCUMENT, NOT the heading). Block operations target - blocks by ``index`` (0-based, against the section's current block list). + blocks by ``index`` (0-based, against the section's current block list) AND + ``anchor``: a verbatim excerpt (~50 characters) of that block's own + ``text``/``items`` content, copied exactly from what you were shown at that + index. ``replace_block`` and ``remove_block`` always need one; for + ``insert_block`` it is the block your new block will land before, needed + only when ``index`` names an existing block (empty string ``""`` when + ``index`` equals the section's block count — you are appending, and there + is nothing at that position to anchor against). A wrong or missing anchor + causes the op to be silently skipped rather than misapplied, so a correct + index alone is not enough — quote it exactly. - **Add** new content with ``append_block``, ``insert_block``, or ``add_section`` when facts introduce information not yet covered. Prefer extending an existing section over creating a new one. @@ -900,9 +911,9 @@ def build_final_system_prompt( ALLOWED OPERATIONS (each line shows the JSON shape) - ``{"op": "append_block", "section_id": "...", "block": {...}}`` -- ``{"op": "insert_block", "section_id": "...", "index": N, "block": {...}}`` -- ``{"op": "replace_block", "section_id": "...", "index": N, "block": {...}}`` -- ``{"op": "remove_block", "section_id": "...", "index": N}`` +- ``{"op": "insert_block", "section_id": "...", "index": N, "anchor": "...", "block": {...}}`` +- ``{"op": "replace_block", "section_id": "...", "index": N, "anchor": "...", "block": {...}}`` +- ``{"op": "remove_block", "section_id": "...", "index": N, "anchor": "..."}`` - ``{"op": "add_section", "heading": "...", "level": 2, "blocks": [...], "after_section_id": "..."}`` - ``{"op": "remove_section", "section_id": "..."}`` - ``{"op": "replace_section_blocks", "section_id": "...", "blocks": [...]}`` @@ -928,18 +939,51 @@ def build_final_system_prompt( "block": {"type": "bullet_list", "items": ["Carol — junior engineer"]}}]}`` - Replace a paragraph that has been corrected by new facts → ``{"operations": [{"op": "replace_block", "section_id": "overview", - "index": 0, "block": {"type": "paragraph", "text": "Updated summary."}}]}`` + "index": 0, "anchor": "Old summary text as shown at index 0", + "block": {"type": "paragraph", "text": "Updated summary."}}]}`` - Remove an obsolete block → - ``{"operations": [{"op": "remove_block", "section_id": "status", "index": 2}]}`` + ``{"operations": [{"op": "remove_block", "section_id": "status", "index": 2, + "anchor": "Text of the block currently at index 2"}]}`` JSON STRING RULES (critical) - Every ``text`` and ``items`` string must be valid JSON: escape ``"`` as ``\\"``, backslashes as ``\\\\``, and newlines as ``\\n``. Do not use raw backticks inside strings unless needed; prefer plain quotes for file paths. -- ``replace_block``, ``insert_block``, and ``remove_block`` MUST include ``index`` (0-based block position in that section). Use ``replace_section_blocks`` only when replacing every block in a section. +- ``replace_block``, ``insert_block``, and ``remove_block`` MUST include ``index`` (0-based block position in that section, as annotated on each block) AND ``anchor`` (verbatim excerpt of the block's own content at that index; empty string ``""`` only for an ``insert_block`` appending at the end). Use ``replace_section_blocks`` only when replacing every block in a section. - Do not append extra ``]`` or ``}`` after the closing ``}`` of the root object.""" +#: The same task, asked without a prior reflect synthesis: the delta fast path +#: calls the model straight off the retrieved window, so SUPPORTING FACTS is the +#: only new evidence and NEW INFORMATION SYNTHESIS comes through empty. That is +#: the whole point of the fast path (one call instead of an agentic loop), but it +#: means the model can genuinely lack what it needs — hence the escape hatch. +#: +#: Deliberately an addendum rather than an edit to the constant above: the agentic +#: path shares that prompt and does NOT read the flag, so a model that answered +#: "I can't do this properly" there would be ignored and its (possibly empty) op +#: list written anyway. Keeping the base prompt byte-identical keeps that path's +#: behaviour byte-identical too. +STRUCTURED_DELTA_FAST_PATH_SYSTEM_PROMPT = ( + STRUCTURED_DELTA_SYSTEM_PROMPT + + """ + +ESCAPE HATCH (supersedes the single-top-level-key rule above) +You are being called without a prior synthesis pass: NEW INFORMATION SYNTHESIS +is empty, and SUPPORTING FACTS is the only new evidence you get. If those facts +are not enough to edit the document correctly — they are ambiguous on their own, +they refer to material you cannot see, or answering the TOPIC properly would +need a broader search of the memory bank — do NOT guess and do NOT make a +best-effort edit. Return exactly: + +``{"operations": [], "needs_full_context": true}`` + +and a slower pass that can retrieve more will take over. + +Otherwise omit ``needs_full_context`` entirely (or set it to false) and return +the ``{"operations": [...]}`` object exactly as described above.""" +) + _STRUCTURED_DELTA_DEFAULT_MAX_INPUT_TOKENS = 24_000 diff --git a/hindsight-api-slim/hindsight_api/engine/reflect/tools.py b/hindsight-api-slim/hindsight_api/engine/reflect/tools.py index 2314fc9c3e..5102097bc0 100644 --- a/hindsight-api-slim/hindsight_api/engine/reflect/tools.py +++ b/hindsight-api-slim/hindsight_api/engine/reflect/tools.py @@ -120,7 +120,7 @@ async def tool_search_mental_models( Dict with matching mental models including content and freshness info """ from ..memory_engine import _may_need_refresh, fq_table - from ..search.tags import build_tag_groups_where_clause, build_tags_where_clause + from ..search.tags import build_tag_groups_where_clause, build_tags_where_clause, strip_entity_leaves # Build filters dynamically filters = "" @@ -135,6 +135,12 @@ async def tool_search_mental_models( filters += f" {tag_clause}" params.extend(tag_params) + if tag_groups: + # This query filters mental_models, which carry no entity postings — + # an entity leaf is unanswerable here and reads permissively (the + # surviving tag constraints still apply). memory_units queries keep + # their entity leaves; only this surface strips. + tag_groups = strip_entity_leaves(tag_groups) if tag_groups: groups_clause, groups_params, next_param = build_tag_groups_where_clause(tag_groups, next_param) filters += f" {groups_clause}" diff --git a/hindsight-api-slim/hindsight_api/engine/search/tags.py b/hindsight-api-slim/hindsight_api/engine/search/tags.py index e6d8fd0c89..c2e1d91917 100644 --- a/hindsight-api-slim/hindsight_api/engine/search/tags.py +++ b/hindsight-api-slim/hindsight_api/engine/search/tags.py @@ -233,6 +233,50 @@ class TagGroupLeaf(BaseModel): match: TagsMatch = "any_strict" +class TagGroupEntityLeaf(BaseModel): + """A leaf ENTITY filter: matches memories by the entities they mention. + + Tags describe which *compartment* a memory lives in; entities describe what it + is *about*. On a bank whose tag vocabulary is a handful of broad topics, a tag + scope cannot isolate a subject — measured on a production bank (2026-08-08), + the best tag scope for one subject reached 25% precision against a 15.5% base + rate, while the entity association reached 94.2% precision / 86.2% recall on + the same corpus. This leaf makes that association usable anywhere a + ``TagGroup`` already is: mental-model refresh scope, the staleness gate, and + retrieval filtering, through the same recursive grammar. + + ``entities`` are canonical names, matched case-insensitively — the same + normalisation the entity registry itself enforces via its + ``(bank_id, LOWER(canonical_name))`` uniqueness. + + Association is inheritance-aware: a memory matches if it links the entity + directly (``unit_entities``) or through any of its ``source_memory_ids`` — the + lane observations use, since consolidation-produced observations carry no + direct postings by design (their entity association is transitive through + their sources; see ``memories/pg/graph.py:_entity_rows_for_units_sql``). + + ``match="any"``: mentions at least one listed entity. ``match="all"``: mentions + every listed entity (directly or via sources, per entity). + + Constraints, enforced by ``validate_entity_leaf_placement`` at the API edge: + an entity leaf may not appear under ``not`` — the two permissive fallbacks + (the Python-side post-filter and the non-memory_units surfaces that strip + entity leaves) evaluate an unknown entity constraint as "matches", and a NOT + over a permissive "matches" silently inverts into "exclude everything". + """ + + entities: list[str] = Field(min_length=1) + # Distinct OpenAPI title. Without it this enum serialises as + # title: "Match", the same title TagGroupLeaf's DIFFERENT match enum + # already uses (any/all/any_strict/all_strict/exact). progenitor/typify + # keys inline enums BY TITLE, so the second definition fails to conform + # and Rust client generation dies with TypeError(InvalidValue) at + # build.rs:199 -- reproduced 2026-08-11. The collision only appears in a + # REGENERATED spec, which is why CI saw it and a local build of the + # committed openapi.json did not. + match: Literal["any", "all"] = Field(default="any", title="EntityMatch") + + class TagGroupAnd(BaseModel): """Compound AND group: all child filters must match.""" @@ -255,10 +299,11 @@ class TagGroupNot(BaseModel): # TagGroup is a discriminated union; Pydantic will try left-to-right. -# TagGroupLeaf is identified by the presence of 'tags'. -# TagGroupAnd / TagGroupOr / TagGroupNot are compound (no 'tags' key). +# TagGroupLeaf is identified by the presence of 'tags'; TagGroupEntityLeaf by +# the presence of 'entities'. TagGroupAnd / TagGroupOr / TagGroupNot are +# compound (neither key). TagGroup = Annotated[ - TagGroupLeaf | TagGroupAnd | TagGroupOr | TagGroupNot, + TagGroupLeaf | TagGroupEntityLeaf | TagGroupAnd | TagGroupOr | TagGroupNot, Field(union_mode="left_to_right"), ] @@ -284,6 +329,51 @@ def _build_group_clause( Returns: (inner_clause, params, next_param_offset) """ + if isinstance(group, TagGroupEntityLeaf): + # Correlated EXISTS against the entity postings. The outer row is referenced + # by fully-qualified table name — every builder call site that filters + # memory_units uses an UNALIASED ``FROM {fq_table("memory_units")}`` + # (semantic/BM25 arms, the staleness read, the scoped listing and window + # reads), and an unaliased table's qualified name is a valid correlation + # ref in both PG and Oracle. A caller that aliases the table, or filters a + # table other than memory_units (the mental-models search, the directives + # listing), must NOT pass entity leaves here — strip them first with + # ``strip_entity_leaves``, which is the permissive reading those surfaces + # want (they carry no entity postings to match against). + # + # Bank scoping is deliberately absent from the subquery: entity links are + # per-unit, and the outer query already constrains bank_id, so a + # same-named entity in another bank has a different id and no link to + # this row — the join cannot cross banks. + # + # Dialect note: ``= ANY($n)`` over an array bind matches the PG-flavoured + # operators the tag leaves above already emit (``@>``/``&&``); Oracle + # translation happens at the same layer it does for those. + from ..schema import fq_table as _fq_table + + mu = _fq_table("memory_units") + ue = _fq_table("unit_entities") + ents = _fq_table("entities") + row_id = f"{table_alias}id" if table_alias else f"{mu}.id" + row_sources = f"{table_alias}source_memory_ids" if table_alias else f"{mu}.source_memory_ids" + names = sorted({n.strip().lower() for n in group.entities if n and n.strip()}) + if not names: + # Whitespace-only names slipping past min_length: no constraint rather + # than invalid SQL. Mirrors the empty-groups contract (no clause). + return "TRUE", [], param_offset + reach = f"(ue.unit_id = {row_id} OR ({row_sources} IS NOT NULL AND ue.unit_id = ANY({row_sources})))" + body = ( + f"FROM {ue} ue JOIN {ents} e ON e.id = ue.entity_id " + f"WHERE LOWER(e.canonical_name) = ANY(${param_offset}) AND {reach}" + ) + if group.match == "all": + # Every listed entity reachable. COUNT(DISTINCT lowered-name) over the + # same body; the target arity is a Python int, inlined as a literal. + clause = f"((SELECT COUNT(DISTINCT LOWER(e.canonical_name)) {body}) = {len(names)})" + else: + clause = f"EXISTS (SELECT 1 {body})" + return clause, [names], param_offset + 1 + if isinstance(group, TagGroupLeaf): column = f"{table_alias}tags" if table_alias else "tags" if group.match == "exact": @@ -389,6 +479,39 @@ def _match_group(result: object, group: TagGroup) -> bool: Returns: True if the result matches the group, False otherwise. """ + if isinstance(group, TagGroupEntityLeaf): + # A retrieval result may carry its entities (recall renders them as + # names or {entity_id, canonical_name} dicts); evaluate against those + # when present. A result that carries NO entity information passes + # permissively — this post-filter refines rows the SQL side already + # scoped, and graph-expansion neighbours legitimately lack entity + # annotations. Safe only because validate_entity_leaf_placement bars + # entity leaves under NOT (a NOT over a permissive pass would invert + # into "drop everything"). + raw = getattr(result, "entities", None) + if not raw: + return True + names: set[str] = set() + for item in raw: + if isinstance(item, str): + names.add(item.lower()) + elif isinstance(item, dict): + name = item.get("canonical_name") or item.get("name") or item.get("text") + if isinstance(name, str): + names.add(name.lower()) + else: + name = getattr(item, "canonical_name", None) or getattr(item, "name", None) + if isinstance(name, str): + names.add(name.lower()) + if not names: + return True + wanted = {n.strip().lower() for n in group.entities if n and n.strip()} + if not wanted: + return True + if group.match == "all": + return wanted <= names + return bool(wanted & names) + if isinstance(group, TagGroupLeaf): result_tags = getattr(result, "tags", None) is_untagged = result_tags is None or len(result_tags) == 0 @@ -423,6 +546,81 @@ def _match_group(result: object, group: TagGroup) -> bool: return True +def validate_entity_leaf_placement(tag_groups: list[TagGroup] | None) -> None: + """Reject entity leaves under ``not`` — raise ValueError naming the constraint. + + SQL evaluates NOT-over-entity correctly, but two surfaces cannot: the + Python-side post-filter passes entity leaves permissively when a result + carries no entity annotations, and the non-memory_units surfaces (the + mental-models search, the directives listing) strip entity leaves outright. + Under a NOT, a permissive "matches" inverts into "exclude everything" on + exactly those surfaces, silently. Barring the placement at the API edge + keeps every fallback sound; lift the bar only by making every consumer + entity-aware first. + """ + + def _walk(node: TagGroup, under_not: bool) -> None: + if isinstance(node, TagGroupEntityLeaf): + if under_not: + raise ValueError( + "an entity filter may not appear under 'not': the post-retrieval " + "and non-memory surfaces evaluate entity leaves permissively, and " + "negating a permissive match silently excludes everything there" + ) + elif isinstance(node, (TagGroupAnd, TagGroupOr)): + for child in node.filters: + _walk(child, under_not) + elif isinstance(node, TagGroupNot): + _walk(node.filter, True) + + for group in tag_groups or []: + _walk(group, False) + + +def strip_entity_leaves(tag_groups: list[TagGroup] | None) -> list[TagGroup] | None: + """Drop entity leaves from a group tree, reading each as "matches". + + For scope filters applied to tables that carry no entity postings (the + mental-models search in ``reflect/tools.py``, the directives listing): an + entity constraint is unanswerable there, and the correct reading is the + permissive one — the surviving tag constraints still apply. Dropping a + permissive leaf from an AND keeps the siblings; from an OR it makes the + whole OR permissive (True OR x), so the OR collapses away; a NOT over an + entity leaf cannot reach here (barred by ``validate_entity_leaf_placement``), + but is dropped defensively rather than inverted. Returns None when nothing + constraining survives. + """ + + def _strip(node: TagGroup) -> TagGroup | None: + if isinstance(node, TagGroupEntityLeaf): + return None + if isinstance(node, TagGroupAnd): + kept = [c for c in (_strip(child) for child in node.filters) if c is not None] + if not kept: + return None + return kept[0] if len(kept) == 1 else TagGroupAnd.model_validate({"and": [_dump(k) for k in kept]}) + if isinstance(node, TagGroupOr): + stripped = [_strip(child) for child in node.filters] + if any(s is None for s in stripped): + return None # a permissive disjunct makes the whole OR permissive + kept = [s for s in stripped if s is not None] + return kept[0] if len(kept) == 1 else TagGroupOr.model_validate({"or": [_dump(k) for k in kept]}) + if isinstance(node, TagGroupNot): + inner = _strip(node.filter) + if inner is None: + return None # defensive: drop, never invert, an unanswerable constraint + return TagGroupNot.model_validate({"not": _dump(inner)}) + return node + + def _dump(node: TagGroup) -> dict: + return node.model_dump(by_alias=True, exclude_none=True) + + if not tag_groups: + return tag_groups + kept_groups = [g for g in (_strip(group) for group in tag_groups) if g is not None] + return kept_groups or None + + def filter_results_by_tag_groups( results: list, tag_groups: list[TagGroup] | None, diff --git a/hindsight-api-slim/tests/test_delta_operation_parse.py b/hindsight-api-slim/tests/test_delta_operation_parse.py index 1dffc7a509..2c729113ab 100644 --- a/hindsight-api-slim/tests/test_delta_operation_parse.py +++ b/hindsight-api-slim/tests/test_delta_operation_parse.py @@ -108,3 +108,57 @@ def test_parse_delta_operation_list_pydantic_instance(): ] ) assert parse_delta_operation_list(original) is original + + +# --------------------------------------------------------------------------- +# needs_full_context — the delta fast path's escape hatch +# --------------------------------------------------------------------------- +# +# The flag has to survive every parse branch. Each one rebuilds the list from the +# operations it validated, so a flag left on the raw payload would be dropped in +# silence and the fast path would apply edits the model had just said it could +# not make safely — the exact failure the hatch exists to prevent. + + +def test_needs_full_context_defaults_to_false(): + assert parse_delta_operation_list('{"operations": []}').needs_full_context is False + assert DeltaOperationList().needs_full_context is False + + +def test_needs_full_context_survives_the_text_branch(): + op_list = parse_delta_operation_list('{"operations": [], "needs_full_context": true}') + assert op_list.needs_full_context is True + assert op_list.operations == [] + + +def test_needs_full_context_survives_the_dict_branch(): + op_list = parse_delta_operation_list({"operations": [], "needs_full_context": True}) + assert op_list.needs_full_context is True + + +def test_needs_full_context_survives_the_pydantic_branch(): + passed_through = DeltaOperationList(needs_full_context=True) + assert parse_delta_operation_list(passed_through).needs_full_context is True + + +def test_needs_full_context_survives_alongside_valid_operations(): + """A model may hedge: emit what it can AND ask for the loop. The flag wins.""" + raw = ( + '{"operations":[{"op":"append_block","section_id":"members",' + '"block":{"type":"bullet_list","items":["Bob"]}}],"needs_full_context":true}' + ) + op_list = parse_delta_operation_list(raw) + assert len(op_list.operations) == 1 + assert op_list.needs_full_context is True + + +@pytest.mark.parametrize( + "raw_flag,expected", + [("true", True), ("TRUE", True), ("false", False), ("yes", False), (1, False), (None, False)], +) +def test_needs_full_context_only_an_explicit_yes_counts(raw_flag, expected): + """The call is text-mode, so a model can answer with a string where a boolean + was asked for. Everything that is not an explicit yes reads as no: the flag + only ever adds a hand-off to the slower path, so guessing from a truthy value + would spend a full agentic pass on an ambiguous answer.""" + assert parse_delta_operation_list({"operations": [], "needs_full_context": raw_flag}).needs_full_context is expected diff --git a/hindsight-api-slim/tests/test_hierarchical_config.py b/hindsight-api-slim/tests/test_hierarchical_config.py index bd09cda95e..a22f40986f 100644 --- a/hindsight-api-slim/tests/test_hierarchical_config.py +++ b/hindsight-api-slim/tests/test_hierarchical_config.py @@ -146,9 +146,10 @@ async def test_hierarchical_fields_categorization(): assert "enable_temporal_retrieval" in configurable assert "enable_graph_retrieval" in configurable assert "enable_reranking" in configurable + assert "mental_model_delta_fast_path" in configurable # Verify count is correct - assert len(configurable) == 45 + assert len(configurable) == 46 # Verify credential fields (NEVER exposed) assert "llm_api_key" in credentials diff --git a/hindsight-api-slim/tests/test_mental_model_delta.py b/hindsight-api-slim/tests/test_mental_model_delta.py index 36f423a8d4..be1c56bfd2 100644 --- a/hindsight-api-slim/tests/test_mental_model_delta.py +++ b/hindsight-api-slim/tests/test_mental_model_delta.py @@ -29,10 +29,18 @@ from hindsight_api import MemoryEngine, RequestContext from hindsight_api.engine.llm_wrapper import LLMConfig from hindsight_api.engine.maintenance import MaintenanceLoop -from hindsight_api.engine.response_models import ReflectResult +from hindsight_api.engine.response_models import ReflectResult, TokenUsage from hindsight_api.engine.retain import embedding_utils +#: Trigger for the tests below that pin the AGENTIC delta path — the reflect loop +#: plus the structured-delta call they patch. The deterministic fast path is on by +#: default and would answer these refreshes itself off the (empty) delta window, +#: never reaching the loop, so they opt out explicitly. The fast path's own tiers +#: are covered by TestDeltaFastPath. +_AGENTIC_DELTA = {"mode": "delta", "delta_fast_path": False} + + def _canned_reflect_result(text: str, facts: list[dict] | None = None) -> ReflectResult: """Build a minimal ReflectResult for monkey-patching reflect_async.""" return ReflectResult.model_validate( @@ -302,7 +310,7 @@ async def test_delta_no_new_facts_advances_watermark_to_newest_processed( name="User Preferences", source_query="What are the user's durable collaboration preferences?", content=existing, - trigger={"mode": "delta", "refresh_cron": "* * * * *"}, + trigger={**_AGENTIC_DELTA, "refresh_cron": "* * * * *"}, request_context=request_context, ) @@ -424,7 +432,7 @@ async def test_delta_refresh_watermark_survives_straddling_commit( name="User Preferences", source_query="What are the user's durable collaboration preferences?", content="# Preferences\n\nThe user prefers concise answers.\n", - trigger={"mode": "delta", "refresh_cron": "* * * * *"}, + trigger={**_AGENTIC_DELTA, "refresh_cron": "* * * * *"}, request_context=request_context, ) @@ -548,7 +556,7 @@ async def test_delta_mode_applies_ops_when_query_stable( name="Team Info", source_query="Tell me about the team", content=existing, - trigger={"mode": "delta"}, + trigger=_AGENTIC_DELTA, request_context=request_context, ) @@ -636,7 +644,7 @@ async def test_delta_prompt_sends_only_new_facts_not_accumulated_history( name="Team Info", source_query="Tell me about the team", content=existing, - trigger={"mode": "delta"}, + trigger=_AGENTIC_DELTA, request_context=request_context, ) @@ -725,7 +733,7 @@ async def test_delta_zero_ops_keeps_existing_content_byte_identical( name="Team Info", source_query="Tell me about the team", content=existing, - trigger={"mode": "delta"}, + trigger=_AGENTIC_DELTA, request_context=request_context, ) # First refresh: parses + renders existing into structured form. The output @@ -779,7 +787,7 @@ async def test_delta_llm_failure_preserves_document_and_raises( name="Team Info", source_query="Tell me about the team", content=existing, - trigger={"mode": "delta"}, + trigger=_AGENTIC_DELTA, request_context=request_context, ) # Seed tracking column + structured baseline with a successful zero-op refresh. @@ -857,7 +865,7 @@ async def test_delta_all_ops_skipped_preserves_document_and_raises( name="Team Info", source_query="Tell me about the team", content=existing, - trigger={"mode": "delta"}, + trigger=_AGENTIC_DELTA, request_context=request_context, ) @@ -926,7 +934,7 @@ async def test_delta_partial_skip_applies_the_rest_and_records_it( name="Team Info", source_query="Tell me about the team", content="# Team\n\nAlice is the lead.\n", - trigger={"mode": "delta"}, + trigger=_AGENTIC_DELTA, request_context=request_context, ) # First refresh establishes the structured doc, so section ids are known. @@ -1004,7 +1012,7 @@ async def test_unusable_structured_content_rebuilds_baseline_from_markdown( name="Team Info", source_query="Tell me about the team", content="# Team\n\nAlice is the lead.\n", - trigger={"mode": "delta"}, + trigger=_AGENTIC_DELTA, request_context=request_context, ) # Valid JSON, wrong shape — what a schema change or a hand edit leaves behind. @@ -1062,7 +1070,7 @@ async def test_unparseable_baseline_preserves_document_and_raises( name="Team Info", source_query="Tell me about the team", content=existing, - trigger={"mode": "delta"}, + trigger=_AGENTIC_DELTA, request_context=request_context, ) @@ -1167,7 +1175,7 @@ async def test_empty_reflect_answer_preserves_existing_content( name="Team Info", source_query="Tell me about the team", content=existing, - trigger={"mode": "delta"}, + trigger=_AGENTIC_DELTA, request_context=request_context, ) @@ -1213,6 +1221,933 @@ async def boom(*, messages, **kwargs): await memory.delete_bank(bank_id, request_context=request_context) +# --------------------------------------------------------------------------- +# Deterministic delta fast path +# --------------------------------------------------------------------------- + + +@pytest.fixture +def patch_window_facts(monkeypatch): + """Patch the two typed retrieval calls the delta fast path makes itself. + + The fast path reads the delta window directly instead of letting the reflect + agent's tools do it, so what that read returns is exactly what decides tier 0 + from tier 1. Patching the two wrappers makes that decision deterministic + without seeding embeddings; ``test_tier1_over_real_retrieval`` covers the + unpatched wiring end to end. + + These are the same module-level names ``reflect_async`` binds its tool + callbacks to, which is harmless here: every test using this fixture also + patches ``reflect_async`` itself. + """ + from hindsight_api.engine import memory_engine as engine_module + + def _install( + memory: MemoryEngine, + *, + memories: list[dict] | None = None, + observations: list[dict] | None = None, + ) -> list[dict]: + calls: list[dict] = [] + + async def fake_recall(_engine, bank_id, query, request_context, **kwargs): + calls.append({"tool": "recall", "bank_id": bank_id, "query": query, **kwargs}) + return {"query": query, "memories": list(memories or []), "chunks": {}} + + async def fake_search_observations(_engine, bank_id, query, request_context, **kwargs): + calls.append({"tool": "search_observations", "bank_id": bank_id, "query": query, **kwargs}) + return { + "query": query, + "count": len(observations or []), + "observations": list(observations or []), + "source_facts": {}, + "is_stale": False, + "freshness": "up_to_date", + } + + monkeypatch.setattr(engine_module, "tool_recall", fake_recall) + monkeypatch.setattr(engine_module, "tool_search_observations", fake_search_observations) + return calls + + return _install + + +@pytest.fixture +def patch_delta_llm_calls(monkeypatch): + """Patch the structured-delta LLM call with one canned response per call. + + Differs from ``patch_llm_call`` in two ways the fast path needs: responses are + consumed in order (so a test can make the fast path decline and then let the + agentic path succeed), and ``return_usage=True`` is honoured, since tier 1 + asks for its own call's usage. Each recorded call also carries the trace + attribution that was bound around it — that is what ends up in the + ``llm_requests`` operation column. + """ + + def _install(memory: MemoryEngine, *, responses: list) -> list[dict]: + calls: list[dict] = [] + queued = list(responses) + assert queued, "at least one canned response is required" + + async def fake_call(*, messages, **kwargs): + from hindsight_api.engine.llm_trace import current_trace_context + + trace_ctx = current_trace_context() + calls.append( + { + "messages": messages, + "operation": trace_ctx.operation if trace_ctx else None, + "trace_bank_id": trace_ctx.bank_id if trace_ctx else None, + **kwargs, + } + ) + response = queued.pop(0) if len(queued) > 1 else queued[0] + if isinstance(response, Exception): + raise response + if kwargs.get("return_usage"): + return response, TokenUsage(input_tokens=1200, output_tokens=90, total_tokens=1290) + return response + + monkeypatch.setattr(memory._reflect_llm_config, "call", fake_call) + return calls + + return _install + + +async def _age_watermark_and_seed_fact( + memory: MemoryEngine, + bank_id: str, + mental_model_id: str, + *, + text: str = "The build server runs Linux.", +): + """Age the model's watermark by a day and commit one in-scope fact. + + Gives the refresh a real delta window (so ``created_after`` is set and the + persisted watermark is the fact's ``updated_at`` rather than the model's own + ``last_refreshed_at``), and records ``last_refreshed_source_query`` so the + mode decision stays in delta. Returns the fact's ``updated_at``. + """ + assert memory._pool is not None + async with memory._pool.acquire() as conn: + await conn.execute( + """ + UPDATE mental_models + SET last_refreshed_at = NOW() - INTERVAL '1 day', + last_refreshed_source_query = source_query + WHERE bank_id = $1 AND id = $2 + """, + bank_id, + mental_model_id, + ) + return await conn.fetchval( + """ + INSERT INTO memory_units (id, bank_id, text, fact_type, tags, created_at, updated_at) + VALUES ($1, $2, $3, 'world', ARRAY[]::varchar[], + NOW() - INTERVAL '2 minutes', NOW() - INTERVAL '2 minutes') + RETURNING updated_at + """, + uuid.uuid4(), + bank_id, + text, + ) + + +_APPEND_BOB_OP = { + "op": "append_block", + "section_id": "members", + "block": {"type": "bullet_list", "items": ["Bob — junior engineer"]}, +} +_NEW_OBSERVATION = { + "id": "obs-bob", + "text": "Bob joined the team as junior engineer", + "fact_type": "observation", + "context": None, +} + + +class TestDeltaFastPath: + """Delta refreshes that never reach the agentic loop. + + Tier 0 reads the window and finds nothing new — no LLM call at all. Tier 1 + turns what it found into edit operations with exactly one. The whole point is + a negative (the loop did not run), so these assert call counts on the mocks + rather than only inspecting the document, which would pass just as happily if + the loop had produced it. + """ + + SOURCE_QUERY = "Tell me about the team" + BASELINE = "# Team\n\nAlice is the lead.\n\n## Members\n\n- Alice — lead\n" + + async def _delta_model( + self, + memory: MemoryEngine, + request_context: RequestContext, + bank_id: str, + *, + trigger: dict | None = None, + content: str | None = None, + ) -> dict: + await memory.get_bank_profile(bank_id, request_context=request_context) + return await memory.create_mental_model( + bank_id=bank_id, + name="Team Info", + source_query=self.SOURCE_QUERY, + content=self.BASELINE if content is None else content, + trigger={"mode": "delta"} if trigger is None else trigger, + request_context=request_context, + ) + + # -- tier 1 ------------------------------------------------------------ + + async def test_tier1_edits_the_document_in_one_call_without_reflect( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_window_facts, + patch_delta_llm_calls, + ): + """The decisive assertion: pending facts, one LLM call, no reflect loop. + + Everything else here (ops applied, content updated, watermark advanced, + history written) already held on the agentic path — the change is that + reaching it costs one call instead of a multi-call loop. + """ + bank_id = f"test-fastpath-tier1-{uuid.uuid4().hex[:8]}" + mm = await self._delta_model(memory, request_context, bank_id) + fact_updated_at = await _age_watermark_and_seed_fact(memory, bank_id, mm["id"]) + + reflect_calls = patch_reflect(memory, text="MUST NOT BE USED") + retrieval = patch_window_facts(memory, observations=[_NEW_OBSERVATION]) + llm_calls = patch_delta_llm_calls(memory, responses=[{"operations": [_APPEND_BOB_OP]}]) + + refreshed = await memory.refresh_mental_model( + bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context + ) + + assert reflect_calls == [], "the agentic reflect loop must not run on the fast path" + assert len(llm_calls) == 1, "tier 1 is exactly one LLM call" + assert {call["tool"] for call in retrieval} == {"recall", "search_observations"} + + assert "Bob — junior engineer" in refreshed["content"] + assert "Alice is the lead." in refreshed["content"], "untouched sections come through unchanged" + rr = refreshed["reflect_response"] + assert rr["fast_path"] == "tier1" + assert rr["fast_path_fallback_reason"] is None + assert rr["delta_applied"] is True + assert [op["op"] for op in rr["delta_operations_applied"]] == ["append_block"] + + # The single call carries the document and the window's facts — not a + # reflect synthesis, which is the call being skipped. + user_msg = llm_calls[0]["messages"][1]["content"] + assert "obs-bob" in user_msg + assert '"members"' in user_msg + + # get_mental_model renders timestamps as ISO strings. + assert refreshed["last_refreshed_at"] == fact_updated_at.isoformat(), ( + "the watermark must advance on a tier-1 write" + ) + history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context) + assert len(history) == 1, "a tier-1 write is recorded in history like any other content write" + + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_tier1_over_real_retrieval( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_delta_llm_calls, + ): + """The same path with nothing between the fast path and the database. + + The other tier-1 tests patch the two retrieval wrappers to keep the tier + decision deterministic; this one retains a real memory and lets the fast + path find it, so the scope, window and budget wiring is exercised rather + than assumed. + """ + bank_id = f"test-fastpath-real-{uuid.uuid4().hex[:8]}" + mm = await self._delta_model(memory, request_context, bank_id) + async with memory._pool.acquire() as conn: + await conn.execute( + """ + UPDATE mental_models + SET last_refreshed_at = NOW() - INTERVAL '1 day', + last_refreshed_source_query = source_query + WHERE bank_id = $1 AND id = $2 + """, + bank_id, + mm["id"], + ) + await memory.retain_batch_async( + bank_id=bank_id, + contents=[{"content": "Bob joined the team as a junior engineer on the platform squad."}], + request_context=request_context, + ) + await memory.wait_for_background_tasks() + + reflect_calls = patch_reflect(memory, text="MUST NOT BE USED") + llm_calls = patch_delta_llm_calls(memory, responses=[{"operations": [_APPEND_BOB_OP]}]) + + refreshed = await memory.refresh_mental_model( + bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context + ) + + assert reflect_calls == [], "the agentic reflect loop must not run on the fast path" + assert len(llm_calls) == 1 + assert refreshed["reflect_response"]["fast_path"] == "tier1" + assert "Bob — junior engineer" in refreshed["content"] + # The retained fact reached the prompt through the real retrieval path. + assert "Bob joined the team" in llm_calls[0]["messages"][1]["content"] + + await memory.delete_bank(bank_id, request_context=request_context) + + # -- tier 0 ------------------------------------------------------------ + + async def test_tier0_costs_no_llm_call_and_advances_the_watermark( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_window_facts, + patch_delta_llm_calls, + ): + """An empty window is answered for free. + + Before the fast path this same outcome cost a full agentic loop first, run + only to discover there was nothing to write. + """ + bank_id = f"test-fastpath-tier0-{uuid.uuid4().hex[:8]}" + mm = await self._delta_model(memory, request_context, bank_id, trigger={"mode": "delta", "keep_trace": True}) + fact_updated_at = await _age_watermark_and_seed_fact(memory, bank_id, mm["id"]) + + reflect_calls = patch_reflect(memory, text="MUST NOT BE USED") + patch_window_facts(memory) # nothing in the window + llm_calls = patch_delta_llm_calls(memory, responses=["MUST NOT BE CALLED"]) + + refreshed = await memory.refresh_mental_model( + bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context + ) + + assert reflect_calls == [], "tier 0 must not run the reflect loop" + assert llm_calls == [], "tier 0 must make no LLM call at all" + + assert refreshed["content"] == self.BASELINE, "content is preserved byte for byte" + rr = refreshed["reflect_response"] + assert rr["fast_path"] == "tier0" + assert rr["delta_applied"] is False + assert rr["delta_skipped_reason"] == "no_new_facts" + + async with memory._pool.acquire() as conn: + newest_in_scope = await conn.fetchval( + "SELECT MAX(updated_at) FROM memory_units WHERE bank_id = $1", bank_id + ) + assert newest_in_scope == fact_updated_at + assert refreshed["last_refreshed_at"] == newest_in_scope.isoformat() + + history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context) + assert history == [], "preserving content is not a new version" + + await memory.delete_bank(bank_id, request_context=request_context) + + # -- usage, trace and attribution -------------------------------------- + + async def test_trace_and_usage_are_coherent_on_both_tiers( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_window_facts, + patch_delta_llm_calls, + ): + """keep_trace on a fast-path refresh records what it actually did. + + Tier 0 books no LLM call and no tokens; tier 1 books exactly its own call. + Both record the retrieval they performed, which is the line that answers + "why did my refresh not pick up my memory" — the reason the trace exists. + """ + bank_id = f"test-fastpath-trace-{uuid.uuid4().hex[:8]}" + mm = await self._delta_model(memory, request_context, bank_id, trigger={"mode": "delta", "keep_trace": True}) + await _age_watermark_and_seed_fact(memory, bank_id, mm["id"]) + patch_reflect(memory, text="MUST NOT BE USED") + + patch_window_facts(memory) + patch_delta_llm_calls(memory, responses=["MUST NOT BE CALLED"]) + tier0 = await memory.refresh_mental_model( + bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context + ) + trace = tier0["reflect_response"]["trace"] + assert trace["fast_path"] == "tier0" + assert trace["effective_mode"] == "delta" + assert trace["outcome"] == "content_preserved_no_new_facts" + assert trace["llm_calls"] == [] + assert trace["usage"]["total_tokens"] == 0 + assert [tc["tool"] for tc in trace["tool_calls"]] == ["recall", "search_observations"] + assert all(tc["result_count"] == 0 for tc in trace["tool_calls"]) + assert all(tc["updated_at"] is not None for tc in trace["tool_calls"]), ( + "both fast-path fetches are window-bounded, so the trace must show the bound" + ) + + patch_window_facts(memory, observations=[_NEW_OBSERVATION]) + patch_delta_llm_calls(memory, responses=[{"operations": [_APPEND_BOB_OP]}]) + tier1 = await memory.refresh_mental_model( + bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context + ) + trace = tier1["reflect_response"]["trace"] + assert trace["fast_path"] == "tier1" + assert trace["outcome"] == "content_written" + assert [lc["scope"] for lc in trace["llm_calls"]] == ["mental_model_delta_ops"] + assert trace["usage"]["total_tokens"] == 1290, "tier 1 books exactly one call's usage" + assert trace["delta_operations"]["applied"], "the operations it applied are on the trace" + + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_delta_ops_call_is_attributed_to_the_refresh_operation( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_window_facts, + patch_delta_llm_calls, + ): + """The delta call must not log with a blank operation. + + It used to: reflect's trace context is already reset by the time the + agentic path makes it, and a bare provider call binds none of its own, so + every structured-delta request landed in llm_requests unattributed and + uncountable. Both routes now bind the refresh's own label. + """ + bank_id = f"test-fastpath-label-{uuid.uuid4().hex[:8]}" + mm = await self._delta_model(memory, request_context, bank_id) + await _age_watermark_and_seed_fact(memory, bank_id, mm["id"]) + patch_reflect(memory, text="MUST NOT BE USED") + patch_window_facts(memory, observations=[_NEW_OBSERVATION]) + llm_calls = patch_delta_llm_calls(memory, responses=[{"operations": [_APPEND_BOB_OP]}]) + + await memory.refresh_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context) + assert llm_calls[0]["operation"] == "refresh_mental_model" + assert llm_calls[0]["trace_bank_id"] == bank_id + assert llm_calls[0]["scope"] == "mental_model_delta_ops" + + # Same guarantee on the agentic route, which makes the same call. + agentic_bank = f"test-agentic-label-{uuid.uuid4().hex[:8]}" + agentic_mm = await self._delta_model(memory, request_context, agentic_bank, trigger=_AGENTIC_DELTA) + patch_reflect( + memory, + text="# Team\n\nNarrow candidate.\n", + facts=[{"id": "obs-new", "text": "Bob joined", "type": "observation", "context": None}], + ) + agentic_calls = patch_delta_llm_calls(memory, responses=[{"operations": [_APPEND_BOB_OP]}]) + await memory.refresh_mental_model( + bank_id=agentic_bank, mental_model_id=agentic_mm["id"], request_context=request_context + ) + assert agentic_calls[0]["operation"] == "refresh_mental_model" + + await memory.delete_bank(bank_id, request_context=request_context) + await memory.delete_bank(agentic_bank, request_context=request_context) + + # -- handing back to the agentic loop ---------------------------------- + + async def test_needs_full_context_hands_back_to_the_reflect_loop( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_window_facts, + patch_delta_llm_calls, + ): + """The escape hatch: the model says the window alone is not enough. + + The fast path trades the loop's retrieval for one call, so the model has + to be able to say it needed that retrieval — otherwise the trade would be + paid for in silently worse edits. + """ + bank_id = f"test-fastpath-escape-{uuid.uuid4().hex[:8]}" + mm = await self._delta_model(memory, request_context, bank_id) + await _age_watermark_and_seed_fact(memory, bank_id, mm["id"]) + + reflect_calls = patch_reflect( + memory, + text="# Team\n\nSynthesis from the full loop.\n", + facts=[{"id": "obs-bob", "text": "Bob joined", "type": "observation", "context": None}], + ) + patch_window_facts(memory, observations=[_NEW_OBSERVATION]) + llm_calls = patch_delta_llm_calls( + memory, + responses=['{"operations": [], "needs_full_context": true}', {"operations": [_APPEND_BOB_OP]}], + ) + + refreshed = await memory.refresh_mental_model( + bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context + ) + + assert len(reflect_calls) == 1, "declining must reach the agentic loop" + assert len(llm_calls) == 2, "the fast path's call, then the loop's own delta call" + rr = refreshed["reflect_response"] + assert rr["fast_path"] is None + assert rr["fast_path_fallback_reason"] == "needs_full_context" + assert rr["delta_applied"] is True + assert "Bob — junior engineer" in refreshed["content"] + + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_all_ops_invalid_hands_back_without_regenerating( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_window_facts, + patch_delta_llm_calls, + ): + """Unparseable operations are the loop's problem, not a reason to rewrite. + + The refresh stays in delta: a full regenerate would read the unbounded + window and replace the document, which is a much larger action than the + one that just failed. + """ + bank_id = f"test-fastpath-invalid-{uuid.uuid4().hex[:8]}" + mm = await self._delta_model(memory, request_context, bank_id) + await _age_watermark_and_seed_fact(memory, bank_id, mm["id"]) + + reflect_calls = patch_reflect( + memory, + text="# Team\n\nSynthesis from the full loop.\n", + facts=[{"id": "obs-bob", "text": "Bob joined", "type": "observation", "context": None}], + ) + patch_window_facts(memory, observations=[_NEW_OBSERVATION]) + patch_delta_llm_calls( + memory, + responses=[ + {"operations": [{"op": "not_an_operation", "section_id": "members"}]}, + {"operations": [_APPEND_BOB_OP]}, + ], + ) + + refreshed = await memory.refresh_mental_model( + bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context + ) + + rr = refreshed["reflect_response"] + assert rr["fast_path"] is None + assert rr["fast_path_fallback_reason"] == "delta_ops_invalid" + assert rr["delta_applied"] is True + assert reflect_calls[0].get("created_after") is not None, ( + "the hand-off must stay a delta refresh, not become a full regenerate" + ) + + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_all_ops_skipped_hands_back_without_regenerating( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_window_facts, + patch_delta_llm_calls, + ): + """Operations that all bounce leave the document untouched — the loop retries. + + Distinct from the invalid case above: these parsed fine and were rejected + when applied, which is the signature of a model editing against section + ids it could not see. + """ + bank_id = f"test-fastpath-skipped-{uuid.uuid4().hex[:8]}" + mm = await self._delta_model(memory, request_context, bank_id) + await _age_watermark_and_seed_fact(memory, bank_id, mm["id"]) + + reflect_calls = patch_reflect( + memory, + text="# Team\n\nSynthesis from the full loop.\n", + facts=[{"id": "obs-bob", "text": "Bob joined", "type": "observation", "context": None}], + ) + patch_window_facts(memory, observations=[_NEW_OBSERVATION]) + missing_section = { + "op": "append_block", + "section_id": "does-not-exist", + "block": {"type": "paragraph", "text": "Bob joined the team."}, + } + patch_delta_llm_calls(memory, responses=[{"operations": [missing_section]}, {"operations": [_APPEND_BOB_OP]}]) + + refreshed = await memory.refresh_mental_model( + bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context + ) + + rr = refreshed["reflect_response"] + assert rr["fast_path"] is None + assert rr["fast_path_fallback_reason"] == "delta_ops_all_skipped" + assert reflect_calls[0].get("created_after") is not None + assert "Bob — junior engineer" in refreshed["content"] + + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_hand_back_into_a_failing_loop_preserves_the_watermark( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_window_facts, + patch_delta_llm_calls, + ): + """Declining must not cost the facts it declined on. + + Once the fast path hands back, the refresh is the agentic one in every + respect — including that a failure preserves both the document and + ``last_refreshed_at``, so the retry reads the same window rather than + skipping past facts that never landed. + """ + bank_id = f"test-fastpath-preserve-{uuid.uuid4().hex[:8]}" + mm = await self._delta_model(memory, request_context, bank_id) + await _age_watermark_and_seed_fact(memory, bank_id, mm["id"]) + before = await memory.get_mental_model(bank_id, mm["id"], request_context=request_context) + + patch_reflect( + memory, + text="# Team\n\nNarrow candidate covering only the new fact.\n", + facts=[{"id": "obs-bob", "text": "Bob joined", "type": "observation", "context": None}], + ) + patch_window_facts(memory, observations=[_NEW_OBSERVATION]) + patch_delta_llm_calls( + memory, + responses=['{"operations": [], "needs_full_context": true}', RuntimeError("simulated provider 500")], + ) + + from hindsight_api.engine.memory_engine import MentalModelRefreshError + + with pytest.raises(MentalModelRefreshError): + await memory.refresh_mental_model( + bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context + ) + + preserved = await memory.get_mental_model(bank_id, mm["id"], request_context=request_context) + assert preserved["content"] == before["content"] + assert preserved["last_refreshed_at"] == before["last_refreshed_at"] + rr = preserved["reflect_response"] + assert rr["refresh_skipped"] == "delta_ops_failed" + assert rr["fast_path_fallback_reason"] == "needs_full_context", "the ledger must still explain why the loop ran" + + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_unreadable_baseline_hands_back_and_the_reason_survives_the_trace( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_window_facts, + patch_delta_llm_calls, + monkeypatch, + ): + """The one hand-back that happens before any LLM call, end to end. + + Also the regression test for the reason vocabulary: ``keep_trace`` builds + ``MentalModelRefreshTrace`` — a Pydantic model whose + ``fast_path_fallback_reason`` is a Literal — for every refresh, failing + ones included, and it does so before the outcome is dispatched. A reason + the engine can emit but the Literal does not list therefore turns a + legible refresh failure into a ValidationError from the trace builder, + with the real cause nowhere in it. + """ + bank_id = f"test-fastpath-nobaseline-{uuid.uuid4().hex[:8]}" + mm = await self._delta_model(memory, request_context, bank_id, trigger={"mode": "delta", "keep_trace": True}) + await _age_watermark_and_seed_fact(memory, bank_id, mm["id"]) + + from hindsight_api.engine.reflect import structured_doc + + def unparseable(_markdown: str): + raise ValueError("simulated unparseable markdown") + + monkeypatch.setattr(structured_doc, "parse_markdown", unparseable) + + patch_reflect( + memory, + text="# Team\n\nNarrow candidate covering only the new fact.\n", + facts=[{"id": "obs-bob", "text": "Bob joined", "type": "observation", "context": None}], + ) + delta_calls = patch_window_facts(memory, observations=[_NEW_OBSERVATION]) + llm_calls = patch_delta_llm_calls(memory, responses=["{}"]) + + from hindsight_api.engine.memory_engine import MentalModelRefreshError + + with pytest.raises(MentalModelRefreshError): + await memory.refresh_mental_model( + bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context + ) + + # Declined before spending anything: no window read, no delta call. + assert delta_calls == [] + assert llm_calls == [] + + preserved = await memory.get_mental_model(bank_id, mm["id"], request_context=request_context) + rr = preserved["reflect_response"] + assert rr["fast_path"] is None + assert rr["fast_path_fallback_reason"] == "no_delta_baseline" + assert rr["trace"]["fast_path_fallback_reason"] == "no_delta_baseline" + # The mode fallback is the agentic path's own verdict on the same + # baseline, recorded separately because the two answer different + # questions: which route ran, and which mode it ran in. + assert rr["trace"]["mode_fallback_reason"] == "structured_doc_unreadable" + + await memory.delete_bank(bank_id, request_context=request_context) + + # -- when the fast path is not consulted at all ------------------------ + + async def test_full_mode_never_reaches_the_fast_path( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_window_facts, + patch_delta_llm_calls, + ): + """Full mode regenerates the whole document, which edit ops cannot express.""" + bank_id = f"test-fastpath-fullmode-{uuid.uuid4().hex[:8]}" + mm = await self._delta_model(memory, request_context, bank_id, trigger={"mode": "full"}) + await _age_watermark_and_seed_fact(memory, bank_id, mm["id"]) + + reflect_calls = patch_reflect(memory, text="# Team\n\nRegenerated from scratch.") + retrieval = patch_window_facts(memory, observations=[_NEW_OBSERVATION]) + llm_calls = patch_delta_llm_calls(memory, responses=["MUST NOT BE CALLED"]) + + refreshed = await memory.refresh_mental_model( + bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context + ) + + assert len(reflect_calls) == 1 + assert retrieval == [], "full mode must not run the fast path's window read" + assert llm_calls == [] + assert refreshed["content"] == "# Team\n\nRegenerated from scratch." + assert refreshed["reflect_response"]["fast_path"] is None + assert refreshed["reflect_response"]["fast_path_fallback_reason"] is None + + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_mode_fallbacks_are_decided_before_the_fast_path( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_window_facts, + patch_delta_llm_calls, + ): + """No baseline and a changed topic still resolve to full mode first. + + Both mean there is nothing to edit surgically, so the fast path is never + consulted — it is strictly a route within delta, not a new mode decision. + """ + no_baseline_bank = f"test-fastpath-nobase-{uuid.uuid4().hex[:8]}" + mm = await self._delta_model(memory, request_context, no_baseline_bank, content="") + patch_reflect(memory, text="# Team\n\nFull fresh synthesis.") + retrieval = patch_window_facts(memory, observations=[_NEW_OBSERVATION]) + patch_delta_llm_calls(memory, responses=["MUST NOT BE CALLED"]) + refreshed = await memory.refresh_mental_model( + bank_id=no_baseline_bank, mental_model_id=mm["id"], request_context=request_context + ) + assert retrieval == [] + assert refreshed["reflect_response"]["fast_path"] is None + + changed_bank = f"test-fastpath-querychg-{uuid.uuid4().hex[:8]}" + changed_mm = await self._delta_model(memory, request_context, changed_bank) + await memory.update_mental_model( + changed_bank, + changed_mm["id"], + last_refreshed_source_query="A completely different question", + request_context=request_context, + ) + patch_reflect(memory, text="# Team\n\nBrand new topic.") + retrieval = patch_window_facts(memory, observations=[_NEW_OBSERVATION]) + refreshed = await memory.refresh_mental_model( + bank_id=changed_bank, mental_model_id=changed_mm["id"], request_context=request_context + ) + assert retrieval == [] + assert refreshed["reflect_response"]["fast_path"] is None + + await memory.delete_bank(no_baseline_bank, request_context=request_context) + await memory.delete_bank(changed_bank, request_context=request_context) + + # -- kill switches ----------------------------------------------------- + + async def test_bank_config_can_switch_the_fast_path_off( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_window_facts, + patch_delta_llm_calls, + ): + """The knob is hierarchical, so one bank can opt out without a redeploy.""" + bank_id = f"test-fastpath-bankoff-{uuid.uuid4().hex[:8]}" + mm = await self._delta_model(memory, request_context, bank_id) + await _age_watermark_and_seed_fact(memory, bank_id, mm["id"]) + await memory.update_bank_config( + bank_id, {"mental_model_delta_fast_path": False}, request_context=request_context + ) + + reflect_calls = patch_reflect( + memory, + text="# Team\n\nSynthesis from the full loop.\n", + facts=[{"id": "obs-bob", "text": "Bob joined", "type": "observation", "context": None}], + ) + retrieval = patch_window_facts(memory, observations=[_NEW_OBSERVATION]) + patch_delta_llm_calls(memory, responses=[{"operations": [_APPEND_BOB_OP]}]) + + refreshed = await memory.refresh_mental_model( + bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context + ) + + assert len(reflect_calls) == 1 + assert retrieval == [] + rr = refreshed["reflect_response"] + assert rr["fast_path"] is None + assert rr["fast_path_fallback_reason"] is None, "never consulted is not the same as declined" + + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_trigger_overrides_the_resolved_default_in_both_directions( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_window_facts, + patch_delta_llm_calls, + ): + """Per-model beats per-bank, off and on.""" + off_bank = f"test-fastpath-trigoff-{uuid.uuid4().hex[:8]}" + off_mm = await self._delta_model(memory, request_context, off_bank, trigger=_AGENTIC_DELTA) + await _age_watermark_and_seed_fact(memory, off_bank, off_mm["id"]) + reflect_calls = patch_reflect( + memory, + text="# Team\n\nSynthesis from the full loop.\n", + facts=[{"id": "obs-bob", "text": "Bob joined", "type": "observation", "context": None}], + ) + retrieval = patch_window_facts(memory, observations=[_NEW_OBSERVATION]) + patch_delta_llm_calls(memory, responses=[{"operations": [_APPEND_BOB_OP]}]) + refreshed = await memory.refresh_mental_model( + bank_id=off_bank, mental_model_id=off_mm["id"], request_context=request_context + ) + assert len(reflect_calls) == 1, "trigger false must win over the default-on knob" + assert retrieval == [] + assert refreshed["reflect_response"]["fast_path"] is None + + on_bank = f"test-fastpath-trigon-{uuid.uuid4().hex[:8]}" + on_mm = await self._delta_model( + memory, request_context, on_bank, trigger={"mode": "delta", "delta_fast_path": True} + ) + await _age_watermark_and_seed_fact(memory, on_bank, on_mm["id"]) + await memory.update_bank_config( + on_bank, {"mental_model_delta_fast_path": False}, request_context=request_context + ) + reflect_calls = patch_reflect(memory, text="MUST NOT BE USED") + patch_window_facts(memory, observations=[_NEW_OBSERVATION]) + llm_calls = patch_delta_llm_calls(memory, responses=[{"operations": [_APPEND_BOB_OP]}]) + refreshed = await memory.refresh_mental_model( + bank_id=on_bank, mental_model_id=on_mm["id"], request_context=request_context + ) + assert reflect_calls == [], "trigger true must win over a bank that switched it off" + assert len(llm_calls) == 1 + assert refreshed["reflect_response"]["fast_path"] == "tier1" + + await memory.delete_bank(off_bank, request_context=request_context) + await memory.delete_bank(on_bank, request_context=request_context) + + def test_env_var_controls_the_default(self, monkeypatch): + """The server-level default reads from the environment. + + Asserted against ``HindsightConfig.from_env`` rather than a live refresh: + the resolver snapshots the global config when the engine is built, so a + mid-test setenv would prove nothing about the engine already running. + """ + from hindsight_api.config import ENV_MENTAL_MODEL_DELTA_FAST_PATH, HindsightConfig + + monkeypatch.delenv(ENV_MENTAL_MODEL_DELTA_FAST_PATH, raising=False) + assert HindsightConfig.from_env().mental_model_delta_fast_path is True + + monkeypatch.setenv(ENV_MENTAL_MODEL_DELTA_FAST_PATH, "false") + assert HindsightConfig.from_env().mental_model_delta_fast_path is False + + monkeypatch.setenv(ENV_MENTAL_MODEL_DELTA_FAST_PATH, "true") + assert HindsightConfig.from_env().mental_model_delta_fast_path is True + + def test_trigger_accepts_true_false_and_none(self): + """The per-model override is tri-state, and validation is otherwise untouched.""" + from hindsight_api.api.http import MentalModelTrigger + + assert MentalModelTrigger().delta_fast_path is None + assert MentalModelTrigger(delta_fast_path=True).delta_fast_path is True + assert MentalModelTrigger(delta_fast_path=False).delta_fast_path is False + + with pytest.raises(ValueError): + MentalModelTrigger(refresh_after_consolidation=True, refresh_cron="0 3 * * *") + + # -- dry run ----------------------------------------------------------- + + async def test_dry_run_previews_both_tiers_and_persists_nothing( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_window_facts, + patch_delta_llm_calls, + ): + """A preview that skipped the fast path would stop predicting the refresh. + + Also pins what ``candidate_content`` means here: the fast path has no + synthesis step, so it reports the document it would write — the current + content on tier 0, the post-operation document on tier 1. + """ + bank_id = f"test-fastpath-dryrun-{uuid.uuid4().hex[:8]}" + mm = await self._delta_model(memory, request_context, bank_id) + await _age_watermark_and_seed_fact(memory, bank_id, mm["id"]) + before = await memory.get_mental_model(bank_id, mm["id"], request_context=request_context) + reflect_calls = patch_reflect(memory, text="MUST NOT BE USED") + + patch_window_facts(memory, observations=[_NEW_OBSERVATION]) + patch_delta_llm_calls(memory, responses=[{"operations": [_APPEND_BOB_OP]}]) + tier1 = await memory.dry_run_refresh_mental_model(bank_id, mm["id"], request_context=request_context) + assert tier1.fast_path == "tier1" + assert tier1.fast_path_fallback_reason is None + assert tier1.effective_mode == "delta" + assert tier1.outcome == "content_written" + assert tier1.would_persist is True + assert "Bob — junior engineer" in tier1.preview_content + assert tier1.candidate_content == tier1.preview_content + assert tier1.diff, "a preview that changes the document must show a diff" + + patch_window_facts(memory) + patch_delta_llm_calls(memory, responses=["MUST NOT BE CALLED"]) + tier0 = await memory.dry_run_refresh_mental_model(bank_id, mm["id"], request_context=request_context) + assert tier0.fast_path == "tier0" + assert tier0.outcome == "content_preserved_no_new_facts" + assert tier0.would_persist is False + assert tier0.candidate_content == tier0.current_content + assert tier0.diff == "" + + assert reflect_calls == [], "neither preview may run the agentic loop" + after = await memory.get_mental_model(bank_id, mm["id"], request_context=request_context) + assert after["content"] == before["content"] + assert after["last_refreshed_at"] == before["last_refreshed_at"] + + await memory.delete_bank(bank_id, request_context=request_context) + + # -- prompt ------------------------------------------------------------- + + def test_fast_path_prompt_extends_the_shared_one_without_changing_it(self): + """The agentic route's prompt must stay byte-identical. + + It shares the structured-delta system prompt but does not read + ``needs_full_context``, so a model answering "I cannot do this properly" + there would be ignored and its empty op list written anyway. The escape + hatch is therefore an addendum, not an edit. + """ + from hindsight_api.engine.reflect.prompts import ( + STRUCTURED_DELTA_FAST_PATH_SYSTEM_PROMPT, + STRUCTURED_DELTA_SYSTEM_PROMPT, + ) + + assert STRUCTURED_DELTA_FAST_PATH_SYSTEM_PROMPT.startswith(STRUCTURED_DELTA_SYSTEM_PROMPT) + assert "needs_full_context" not in STRUCTURED_DELTA_SYSTEM_PROMPT + assert "needs_full_context" in STRUCTURED_DELTA_FAST_PATH_SYSTEM_PROMPT + + # --------------------------------------------------------------------------- # Real-Gemini evaluation tests # --------------------------------------------------------------------------- diff --git a/hindsight-api-slim/tests/test_mental_model_dry_run_refresh.py b/hindsight-api-slim/tests/test_mental_model_dry_run_refresh.py index 30dc6256b8..aa57ffae58 100644 --- a/hindsight-api-slim/tests/test_mental_model_dry_run_refresh.py +++ b/hindsight-api-slim/tests/test_mental_model_dry_run_refresh.py @@ -254,7 +254,9 @@ async def test_delta_ops_failure_reports_the_refused_refresh( name="Team Info", source_query="Tell me about the team", content="# Team\n\nYears of accumulated detail.", - trigger={"mode": "delta"}, + # Pins the agentic delta path: the failing call under test is the one + # the reflect loop makes, which the fast path would otherwise pre-empt. + trigger={"mode": "delta", "delta_fast_path": False}, request_context=request_context, ) await memory.update_mental_model( @@ -586,6 +588,8 @@ async def test_trace_is_recorded_when_enabled( "effective_mode", "mode_fallback_reason", "outcome", + "fast_path", + "fast_path_fallback_reason", "tool_calls", "llm_calls", "delta_operations", diff --git a/hindsight-api-slim/tests/test_mental_model_structured_output.py b/hindsight-api-slim/tests/test_mental_model_structured_output.py index b810201d6e..1cbfac75f7 100644 --- a/hindsight-api-slim/tests/test_mental_model_structured_output.py +++ b/hindsight-api-slim/tests/test_mental_model_structured_output.py @@ -136,7 +136,9 @@ async def test_delta_extracts_from_merged_content( name="Doc", source_query="doc?", content="# Doc\n\n## Section A\n\nOriginal body.", - trigger={"mode": "delta", "response_schema": _SCHEMA}, + # delta_fast_path off: this pins the agentic path, whose reflect answer + # ("PARTIAL DELTA ANSWER") is exactly what structured output must NOT read. + trigger={"mode": "delta", "response_schema": _SCHEMA, "delta_fast_path": False}, request_context=request_context, ) diff --git a/hindsight-api-slim/tests/test_refresh_outcome_metadata.py b/hindsight-api-slim/tests/test_refresh_outcome_metadata.py index 13629b47bd..ed6be594cc 100644 --- a/hindsight-api-slim/tests/test_refresh_outcome_metadata.py +++ b/hindsight-api-slim/tests/test_refresh_outcome_metadata.py @@ -1,117 +1,138 @@ -"""Refresh operations expose semantic outcome fields in result_metadata (#2605). - -Retain operations have carried machine-readable outcome metadata since 0.8.x -(``unit_ids_count`` etc.). These tests pin the refresh-side parity: a completed -refresh_mental_model operation must let a monitoring layer distinguish -"refreshed with real content" from "refreshed empty" by reading -``result_metadata`` alone, without a follow-up content fetch. +"""Tests for refresh outcome metadata, specifically the SERVING TIER field. + +Why this exists: a two-tier refresh system whose tier is unobservable after the fact +cannot be operated or measured. Before this field, the tier was only readable from +`mental_models.reflect_response.fast_path`, which holds only each model's LATEST +refresh and is overwritten by the next one -- so tier distribution over any window was +unrecoverable from the database, and had to be reconstructed by an external sampler +polling every 5 minutes. + +These tests drive the real `_write_refresh_outcome_metadata` with a fake connection and +assert on the JSON it actually persists, rather than on the dataclass in isolation -- +the mapping (`fast_path` -> `serving_tier`, with None normalised to "tier2") is the part +that can be wrong. """ -import asyncio +from __future__ import annotations + +import json import uuid import pytest -from hindsight_api.engine.memory_engine import MemoryEngine - -# The reflect agent's fallback answer when the LLM returns nothing usable -# (hindsight_api/engine/reflect/agent.py). Non-empty, so it survives the -# empty-content guard in refresh_mental_model and completes wire-successful — -# exactly the case populated_content must expose. -NO_ANSWER_STUB = "No answer provided." - - -@pytest.fixture -async def bank_with_model(memory: MemoryEngine, request_context): - """Bank with one mental model, unique per test for xdist safety.""" - bank_id = f"test-refresh-meta-{uuid.uuid4().hex[:8]}" - await memory.get_bank_profile(bank_id, request_context=request_context) - mm = await memory.create_mental_model( - bank_id=bank_id, - name="Outcome Meta Model", - source_query="What outcome fields does refresh expose?", - content="Original content", - request_context=request_context, - ) - yield memory, bank_id, mm - await memory.delete_bank(bank_id, request_context=request_context) +from hindsight_api.engine import memory_engine as me +from hindsight_api.engine.operation_metadata import RefreshMentalModelOutcomeMetadata -def _fake_refreshed(content: str, based_on: dict) -> dict: - """Shape of refresh_mental_model's return value as consumed by the handler.""" - return { - "content": content, - "reflect_response": {"text": content, "based_on": based_on, "mental_models": []}, - "source_query": "What outcome fields does refresh expose?", - } +class _FakeConn: + """Captures the parameters of the UPDATE the writer issues.""" + def __init__(self) -> None: + self.executed: list[tuple] = [] -async def _submit_with_fake_refresh(memory, monkeypatch, bank_id, mm, request_context, refreshed): - """Submit an async refresh whose reflect outcome is stubbed to `refreshed`. + async def execute(self, sql: str, *params): + self.executed.append((sql, params)) - The patch must land before submission: the test task backend executes the - queued task synchronously on submit, so this exercises the real path - (execute_task -> _handle_refresh_mental_model -> metadata write). - """ - async def fake_refresh(bank_id, mental_model_id, *, request_context): - return refreshed +class _FakeAcquire: + def __init__(self, conn: _FakeConn) -> None: + self._conn = conn - monkeypatch.setattr(memory, "refresh_mental_model", fake_refresh) - result = await memory.submit_async_refresh_mental_model( - bank_id=bank_id, - mental_model_id=mm["id"], - request_context=request_context, - ) - await asyncio.sleep(0.1) - return result["operation_id"] + async def __aenter__(self) -> _FakeConn: + return self._conn + async def __aexit__(self, *exc) -> bool: + return False -@pytest.mark.asyncio -async def test_completed_refresh_enriches_result_metadata(bank_with_model, request_context, monkeypatch): - """A completed refresh writes content_len / populated_content / based_on_counts.""" - memory, bank_id, mm = bank_with_model - content = "x" * 120 - based_on = { - "world": [{"id": "f1"}, {"id": "f2"}, {"id": "f3"}], - "mental-models": [{"id": "m1"}], - } - - operation_id = await _submit_with_fake_refresh( - memory, monkeypatch, bank_id, mm, request_context, _fake_refreshed(content, based_on) - ) - status = await memory.get_operation_status( - bank_id=bank_id, operation_id=operation_id, request_context=request_context - ) - assert status["status"] == "completed" - meta = status["result_metadata"] +class _StubEngine: + """Minimal stand-in carrying only what the writer touches.""" + + async def _get_backend(self): + return object() - # Submit-time keys are merged with, not replaced by, the outcome fields: - # existing consumers join on mental_model_id/name. - assert meta["mental_model_id"] == mm["id"] - assert meta["name"] == "Outcome Meta Model" - assert meta["content_len"] == 120 - assert meta["populated_content"] is True - assert meta["based_on_counts"] == {"world": 3, "mental-models": 1} +async def _write(monkeypatch, refreshed: dict) -> dict: + """Run the real writer against a fake conn; return the metadata JSON it persisted.""" + conn = _FakeConn() + monkeypatch.setattr(me, "acquire_with_retry", lambda backend: _FakeAcquire(conn)) + op_id = str(uuid.uuid4()) + await me.MemoryEngine._write_refresh_outcome_metadata(_StubEngine(), op_id, refreshed) + assert conn.executed, "writer issued no UPDATE" + _sql, params = conn.executed[-1] + return json.loads(params[1]) @pytest.mark.asyncio -async def test_no_answer_stub_reads_as_unpopulated(bank_with_model, request_context, monkeypatch): - """The historical 19-char stub completes wire-successful but must not read as populated.""" - memory, bank_id, mm = bank_with_model +async def test_tier0_is_recorded(monkeypatch): + meta = await _write( + monkeypatch, + { + "content": "preserved document", + "reflect_response": {"fast_path": "tier0", "fast_path_fallback_reason": None}, + }, + ) + assert meta["serving_tier"] == "tier0" + assert meta["fast_path_fallback_reason"] is None + - operation_id = await _submit_with_fake_refresh( - memory, monkeypatch, bank_id, mm, request_context, _fake_refreshed(NO_ANSWER_STUB, {}) +@pytest.mark.asyncio +async def test_tier1_is_recorded(monkeypatch): + meta = await _write( + monkeypatch, + { + "content": "edited document", + "reflect_response": { + "fast_path": "tier1", + "fast_path_fallback_reason": None, + "delta_operations_applied": [{"op": "replace_block"}, {"op": "append_block"}], + "delta_operations_skipped": [{"op": "replace_block"}], + }, + }, ) + assert meta["serving_tier"] == "tier1" + assert meta["delta_ops_applied"] == 2 + assert meta["delta_ops_skipped"] == 1 + - status = await memory.get_operation_status( - bank_id=bank_id, operation_id=operation_id, request_context=request_context +@pytest.mark.asyncio +async def test_agentic_loop_normalises_to_tier2_with_its_reason(monkeypatch): + """The load-bearing case: `fast_path` is None on the agentic path. + + Persisting that null verbatim would be ambiguous between "the agentic loop ran" + and "written by a build predating this field", so it is normalised to "tier2" and + the hand-back reason is carried alongside. + """ + meta = await _write( + monkeypatch, + { + "content": "regenerated document", + "reflect_response": {"fast_path": None, "fast_path_fallback_reason": "needs_full_context"}, + }, ) - assert status["status"] == "completed" - meta = status["result_metadata"] + assert meta["serving_tier"] == "tier2" + assert meta["fast_path_fallback_reason"] == "needs_full_context" - assert meta["content_len"] == len(NO_ANSWER_STUB) - assert meta["populated_content"] is False - assert meta["based_on_counts"] == {} + +@pytest.mark.asyncio +async def test_missing_fast_path_key_still_yields_a_tier(monkeypatch): + """A reflect_response from an older build has no `fast_path` key at all.""" + meta = await _write(monkeypatch, {"content": "doc", "reflect_response": {}}) + assert meta["serving_tier"] == "tier2" + + +@pytest.mark.asyncio +async def test_no_operation_id_is_a_noop(monkeypatch): + """Guard the early return -- a refresh outside an operation must not raise.""" + conn = _FakeConn() + monkeypatch.setattr(me, "acquire_with_retry", lambda backend: _FakeAcquire(conn)) + await me.MemoryEngine._write_refresh_outcome_metadata(_StubEngine(), None, {"content": "x"}) + assert conn.executed == [] + + +def test_tier_fields_default_to_none_for_existing_callers(): + """Both fields are optional, so callers constructed before them still work.""" + meta = RefreshMentalModelOutcomeMetadata(content_len=10, populated_content=True) + assert meta.serving_tier is None + assert meta.fast_path_fallback_reason is None + assert meta.to_dict()["serving_tier"] is None diff --git a/hindsight-api-slim/tests/test_structured_doc.py b/hindsight-api-slim/tests/test_structured_doc.py index 7b95f36cf8..ada5f354ad 100644 --- a/hindsight-api-slim/tests/test_structured_doc.py +++ b/hindsight-api-slim/tests/test_structured_doc.py @@ -14,6 +14,8 @@ from __future__ import annotations +import json + import pytest from hindsight_api.engine.reflect.delta_ops import ( @@ -27,6 +29,7 @@ ReplaceBlockOp, ReplaceSectionBlocksOp, apply_operations, + serialize_document_for_delta_prompt, ) from hindsight_api.engine.reflect.structured_doc import ( BulletListBlock, @@ -348,6 +351,9 @@ def test_insert_block_at_index(self): op = InsertBlockOp( section_id="members", index=0, + # Verbatim excerpt of the block currently at index 0 (the bullet + # list this insert lands before). + anchor="**Alice** — team lead", block=ParagraphBlock(text="Roster as of 2026:"), ) result = apply_operations(doc, [op]) @@ -362,11 +368,39 @@ def test_insert_block_out_of_range_skipped(self): assert result.applied == [] assert "index out of range" in result.skipped[0]["reason"] + def test_insert_block_at_append_position_needs_no_anchor(self): + """index == len(blocks) is a pure append: there is no existing block + there to anchor against, so an empty/omitted anchor still applies.""" + doc = _team_overview_doc() + members_len = len(doc.section_by_id("members").blocks) + op = InsertBlockOp(section_id="members", index=members_len, block=ParagraphBlock(text="Appended.")) + result = apply_operations(doc, [op]) + assert result.applied + members = result.document.section_by_id("members") + assert members.blocks[-1].text == "Appended." + + def test_insert_block_wrong_anchor_skipped(self): + """index < len(blocks) DOES name an existing block, so a wrong + anchor there must still be caught.""" + doc = _team_overview_doc() + op = InsertBlockOp( + section_id="members", + index=0, + anchor="Standups happen daily", # content from a different section + block=ParagraphBlock(text="x"), + ) + result = apply_operations(doc, [op]) + assert result.applied == [] + assert "anchor" in result.skipped[0]["reason"] + # Nothing was inserted. + assert len(result.document.section_by_id("members").blocks) == 1 + def test_replace_block(self): doc = _team_overview_doc() op = ReplaceBlockOp( section_id="cadence", index=0, + anchor="Standups happen daily at 9am.", block=ParagraphBlock(text="Standups happen daily at 10am."), ) result = apply_operations(doc, [op]) @@ -374,13 +408,84 @@ def test_replace_block(self): assert isinstance(cadence.blocks[0], ParagraphBlock) assert cadence.blocks[0].text.endswith("10am.") + def test_replace_block_missing_anchor_skipped(self): + doc = _team_overview_doc() + op = ReplaceBlockOp(section_id="cadence", index=0, block=ParagraphBlock(text="new text")) + result = apply_operations(doc, [op]) + assert result.applied == [] + assert result.skipped[0]["reason"] == "missing anchor" + # Original content untouched. + assert result.document.section_by_id("cadence").blocks[0].text == "Standups happen daily at 9am." + + def test_replace_block_anchor_whitespace_normalized(self): + """Irregular whitespace in the quoted anchor (extra spaces, a + line-wrapped newline) must not cause a false mismatch.""" + doc = _team_overview_doc() + op = ReplaceBlockOp( + section_id="cadence", + index=0, + anchor="Standups happen\ndaily at 9am.", + block=ParagraphBlock(text="Standups happen daily at 10am."), + ) + result = apply_operations(doc, [op]) + assert result.applied + assert result.document.section_by_id("cadence").blocks[0].text.endswith("10am.") + + def test_off_by_one_index_with_neighbor_anchor_is_skipped(self): + """Regression test for the measured production defect: a delta + refresh computed an off-by-one ``index`` (miscounting an 8-block + section's array elements) and replaced the wrong block, because + range validation alone cannot tell a wrong-but-in-range index from a + correct one. + + Here the op claims ``index=4`` but its anchor actually names the + content of the block at index 5 (its neighbor) -- exactly the shape + of the measured defect, where the model's intended target and its + claimed index pointed at different blocks. The anchor guard must + catch the mismatch and skip the op rather than silently overwriting + block 4 with content meant for block 5. + """ + blocks = [ParagraphBlock(text=f"Paragraph number {i} of the long section.") for i in range(8)] + doc = StructuredDocument(sections=[Section(id="long", heading="Long Section", level=2, blocks=blocks)]) + neighbor_text = blocks[5].text + op = ReplaceBlockOp( + section_id="long", + index=4, + anchor=neighbor_text[:40], + block=ParagraphBlock(text="REPLACED CONTENT"), + ) + result = apply_operations(doc, [op]) + assert result.applied == [] + assert len(result.skipped) == 1 + assert "anchor" in result.skipped[0]["reason"] + section = result.document.section_by_id("long") + # Neither the wrongly-targeted block (4) nor its neighbor (5) changed. + assert section.blocks[4].model_dump() == blocks[4].model_dump() + assert section.blocks[5].model_dump() == blocks[5].model_dump() + def test_remove_block(self): doc = _team_overview_doc() - op = RemoveBlockOp(section_id="members", index=0) + op = RemoveBlockOp(section_id="members", index=0, anchor="**Alice** — team lead") result = apply_operations(doc, [op]) members = result.document.section_by_id("members") assert members.blocks == [] + def test_remove_block_missing_anchor_skipped(self): + doc = _team_overview_doc() + op = RemoveBlockOp(section_id="members", index=0) + result = apply_operations(doc, [op]) + assert result.applied == [] + assert result.skipped[0]["reason"] == "missing anchor" + assert len(result.document.section_by_id("members").blocks) == 1 + + def test_remove_block_wrong_anchor_skipped(self): + doc = _team_overview_doc() + op = RemoveBlockOp(section_id="members", index=0, anchor="Standups happen daily") + result = apply_operations(doc, [op]) + assert result.applied == [] + assert "anchor" in result.skipped[0]["reason"] + assert len(result.document.section_by_id("members").blocks) == 1 + def test_add_section_at_end(self): doc = _team_overview_doc() op = AddSectionOp( @@ -468,6 +573,49 @@ def test_unmodified_sections_byte_identical_in_render(self): assert before_cadence == after_cadence +class TestSerializeDocumentForDeltaPrompt: + """``serialize_document_for_delta_prompt`` is the prompt-facing view that + annotates each block with its own index, so the model reads its position + instead of silently counting array elements (the root cause behind the + off-by-one regression tested above). The tests below check that this + annotation is absent from the real persisted dump, and that feeding the + annotated view back through ``StructuredDocument``'s own strict schema + validation (the only parse path currently used to reconstruct a + document) is rejected rather than silently accepted. + """ + + def test_annotates_each_block_with_its_index(self): + doc = _team_overview_doc() + payload = json.loads(serialize_document_for_delta_prompt(doc)) + members_section = next(s for s in payload["sections"] if s["id"] == "members") + assert members_section["blocks"][0]["index"] == 0 + cadence_section = next(s for s in payload["sections"] if s["id"] == "cadence") + assert cadence_section["blocks"][0]["index"] == 0 + + def test_multi_block_section_indices_are_sequential(self): + blocks = [ParagraphBlock(text=f"p{i}") for i in range(5)] + doc = StructuredDocument(sections=[Section(id="s", heading="S", level=2, blocks=blocks)]) + payload = json.loads(serialize_document_for_delta_prompt(doc)) + assert [b["index"] for b in payload["sections"][0]["blocks"]] == [0, 1, 2, 3, 4] + + def test_index_annotation_does_not_leak_into_persisted_document(self): + doc = _team_overview_doc() + # None of this fixture's blocks carry an "index" key in the real + # persisted dump (checked for every section/block in _team_overview_doc). + persisted = json.loads(doc.model_dump_json()) + for section in persisted["sections"]: + for block in section["blocks"]: + assert "index" not in block + + # The annotated, prompt-facing view is rejected by the strict + # (extra="forbid") Block/StructuredDocument schema when fed back + # through model_validate -- the same validation path used to parse + # a document today -- rather than being silently accepted. + annotated_payload = json.loads(serialize_document_for_delta_prompt(doc)) + with pytest.raises(Exception): # pydantic ValidationError + StructuredDocument.model_validate(annotated_payload) + + class TestDeltaOperationListSchema: """Sanity-check that the discriminated-union schema serialises as the LLM will see it: each op has a literal ``op`` string that picks the variant. diff --git a/hindsight-api-slim/tests/test_tag_group_entity_leaf.py b/hindsight-api-slim/tests/test_tag_group_entity_leaf.py new file mode 100644 index 0000000000..f3cd0a6e42 --- /dev/null +++ b/hindsight-api-slim/tests/test_tag_group_entity_leaf.py @@ -0,0 +1,388 @@ +"""Tests for the entity leaf in the tag-group grammar. + +Why this exists: on a bank whose tag vocabulary is a handful of broad topics, a +tag scope cannot isolate a subject (measured 2026-08-08: best tag scope 25% +precision vs 15.5% base rate, entity association 94.2%/86.2%). The entity leaf +makes that association expressible everywhere a TagGroup already flows: the +mental-model refresh scope, the staleness gate, and retrieval filtering. + +Layers covered, cheapest first: + 1. schema: parse/validate, incl. the not-placement bar + 2. SQL: the clause the builder emits, param accounting, `all` arity + 3. strip: the permissive reading for surfaces without entity postings + 4. python matcher: post-retrieval refinement + the permissive fallback + 5. database: `any_memory_updated_since` (the staleness gate) with direct and + source-inherited entity association -- the load-bearing observation case +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone + +import pytest +from pydantic import TypeAdapter + +from hindsight_api.engine.memories.pg.reads import any_memory_updated_since +from hindsight_api.engine.memory_engine import MemoryEngine, fq_table +from hindsight_api.engine.search.tags import ( + TagGroup, + TagGroupEntityLeaf, + build_tag_groups_where_clause, + filter_results_by_tag_groups, + strip_entity_leaves, + validate_entity_leaf_placement, +) + +_ADAPTER = TypeAdapter(TagGroup) + + +# ---------------------------------------------------------------- 1. schema + + +def test_entity_leaf_parses_from_dict(): + leaf = _ADAPTER.validate_python({"entities": ["atlas", "Northstar"]}) + assert isinstance(leaf, TagGroupEntityLeaf) + assert leaf.match == "any" + + +def test_entity_leaf_parses_nested_in_compounds(): + group = _ADAPTER.validate_python( + { + "or": [ + {"entities": ["atlas"]}, + {"and": [{"tags": ["topic:infra"]}, {"entities": ["Northstar"], "match": "all"}]}, + ] + } + ) + validate_entity_leaf_placement([group]) # no NOT anywhere -> fine + + +def test_empty_entities_rejected(): + with pytest.raises(Exception): + _ADAPTER.validate_python({"entities": []}) + + +def test_entity_leaf_under_not_rejected(): + group = _ADAPTER.validate_python({"not": {"entities": ["atlas"]}}) + with pytest.raises(ValueError, match="entity filter may not appear under 'not'"): + validate_entity_leaf_placement([group]) + + +def test_entity_leaf_under_nested_not_rejected(): + group = _ADAPTER.validate_python({"not": {"or": [{"tags": ["a"]}, {"entities": ["atlas"]}]}}) + with pytest.raises(ValueError): + validate_entity_leaf_placement([group]) + + +def test_tag_only_groups_pass_placement_validation(): + group = _ADAPTER.validate_python({"not": {"tags": ["topic:infra"]}}) + validate_entity_leaf_placement([group]) + + +# ------------------------------------------------------------------- 2. SQL + + +def test_sql_any_emits_correlated_exists_with_lowered_names(): + leaf = _ADAPTER.validate_python({"entities": ["Atlas", "NORTHSTAR", " atlas "]}) + clause, params, next_offset = build_tag_groups_where_clause([leaf], param_offset=3) + assert "EXISTS" in clause + assert "LOWER(e.canonical_name) = ANY($3)" in clause + # correlation against the outer memory_units row, direct OR through sources + assert ".id" in clause and "source_memory_ids" in clause + assert params == [["atlas", "northstar"]] # lowered, trimmed, deduped, sorted + assert next_offset == 4 + + +def test_sql_all_emits_distinct_count_arity(): + leaf = _ADAPTER.validate_python({"entities": ["atlas", "Northstar"], "match": "all"}) + clause, params, _ = build_tag_groups_where_clause([leaf], param_offset=1) + assert "COUNT(DISTINCT LOWER(e.canonical_name))" in clause + assert clause.rstrip(")").endswith("= 2") + assert params == [["atlas", "northstar"]] + + +def test_sql_mixed_tags_and_entities_param_accounting(): + group = _ADAPTER.validate_python( + {"and": [{"tags": ["topic:infra"], "match": "any_strict"}, {"entities": ["atlas"]}]} + ) + clause, params, next_offset = build_tag_groups_where_clause([group], param_offset=5) + assert "$5" in clause and "$6" in clause + assert params == [["topic:infra"], ["atlas"]] + assert next_offset == 7 + + +# ------------------------------------------------------------------ 3. strip + + +def _strip_dicts(*groups: dict) -> list | None: + return strip_entity_leaves([_ADAPTER.validate_python(g) for g in groups]) + + +def test_strip_lone_entity_leaf_yields_none(): + assert _strip_dicts({"entities": ["atlas"]}) is None + + +def test_strip_keeps_tag_siblings_in_and(): + kept = _strip_dicts({"and": [{"tags": ["topic:infra"]}, {"entities": ["atlas"]}]}) + assert kept is not None and len(kept) == 1 + clause, params, _ = build_tag_groups_where_clause(kept, param_offset=1) + assert "EXISTS" not in clause and params == [["topic:infra"]] + + +def test_strip_collapses_or_containing_entity_leaf(): + # True OR x is True: the whole OR becomes permissive and disappears. + assert _strip_dicts({"or": [{"tags": ["topic:infra"]}, {"entities": ["atlas"]}]}) is None + + +def test_strip_leaves_tag_only_groups_untouched(): + kept = _strip_dicts({"tags": ["topic:infra"], "match": "all_strict"}) + assert kept is not None and len(kept) == 1 + + +# --------------------------------------------------------- 4. python matcher + + +class _Result: + def __init__(self, tags=None, entities=None): + self.tags = tags + self.entities = entities + + +def test_matcher_filters_by_entity_names_when_present(): + groups = [_ADAPTER.validate_python({"entities": ["atlas"]})] + hit = _Result(entities=["atlas", "Northstar"]) + miss = _Result(entities=[{"canonical_name": "Docker"}]) + assert filter_results_by_tag_groups([hit, miss], groups) == [hit] + + +def test_matcher_reads_dict_shaped_entities(): + groups = [_ADAPTER.validate_python({"entities": ["northstar"]})] + hit = _Result(entities=[{"entity_id": "x", "canonical_name": "Northstar"}]) + assert filter_results_by_tag_groups([hit], groups) == [hit] + + +def test_matcher_passes_results_without_entity_annotations(): + groups = [_ADAPTER.validate_python({"entities": ["atlas"]})] + unannotated = _Result(entities=None) + assert filter_results_by_tag_groups([unannotated], groups) == [unannotated] + + +def test_matcher_all_requires_every_name(): + groups = [_ADAPTER.validate_python({"entities": ["atlas", "northstar"], "match": "all"})] + both = _Result(entities=["atlas", "Northstar", "extra"]) + one = _Result(entities=["atlas"]) + assert filter_results_by_tag_groups([both, one], groups) == [both] + + +# -------------------------------------------------------------- 5. database + + +async def _mk_unit(conn, bank_id: str, text: str, fact_type: str = "world", sources: list[str] | None = None) -> str: + uid = str(uuid.uuid4()) + await conn.execute( + """ + INSERT INTO memory_units (id, bank_id, text, fact_type, source_memory_ids, created_at, updated_at) + VALUES ($1::uuid, $2, $3, $4, $5::uuid[], now(), now()) + """, + uid, + bank_id, + text, + fact_type, + sources, + ) + return uid + + +async def _mk_entity(conn, bank_id: str, name: str) -> str: + eid = str(uuid.uuid4()) + await conn.execute( + """ + INSERT INTO entities (id, bank_id, canonical_name, entity_kind, first_seen, last_seen, mention_count) + VALUES ($1::uuid, $2, $3, 'regular', now(), now(), 1) + """, + eid, + bank_id, + name, + ) + return eid + + +async def _link(conn, unit_id: str, entity_id: str) -> None: + await conn.execute( + "INSERT INTO unit_entities (unit_id, entity_id) VALUES ($1::uuid, $2::uuid) ON CONFLICT DO NOTHING", + unit_id, + entity_id, + ) + + +def _groups(*dicts: dict) -> list: + return [_ADAPTER.validate_python(d) for d in dicts] + + +@pytest.mark.asyncio +async def test_staleness_sees_direct_entity_match(memory: MemoryEngine): + """any_memory_updated_since is the staleness gate; an entity-scoped model + must go stale exactly when a fact ABOUT its entities arrives.""" + bank_id = f"test-ent-stale-{uuid.uuid4().hex[:8]}" + since = datetime.now(timezone.utc) - timedelta(minutes=5) + async with memory._pool.acquire() as conn: + e_atlas = await _mk_entity(conn, bank_id, "atlas") + unit = await _mk_unit(conn, bank_id, "the cli opened an unexpected window.") + await _link(conn, unit, e_atlas) + + assert ( + await any_memory_updated_since( + conn=conn, + fq_table=fq_table, + bank_id=bank_id, + since=since, + tag_groups=_groups({"entities": ["Atlas"]}), # case-insensitive + ) + is True + ) + assert ( + await any_memory_updated_since( + conn=conn, + fq_table=fq_table, + bank_id=bank_id, + since=since, + tag_groups=_groups({"entities": ["docker"]}), + ) + is False + ) + + +@pytest.mark.asyncio +async def test_staleness_sees_source_inherited_match_for_observations(memory: MemoryEngine): + """The load-bearing case: observations carry NO direct postings by design; + their entity association is transitive through source_memory_ids. An + entity-scoped model reading observations must still go stale when an + observation built from entity-linked sources arrives.""" + bank_id = f"test-ent-stale-{uuid.uuid4().hex[:8]}" + since = datetime.now(timezone.utc) - timedelta(minutes=5) + async with memory._pool.acquire() as conn: + e_gem = await _mk_entity(conn, bank_id, "Northstar") + src = await _mk_unit(conn, bank_id, "Northstar shim fact.") + await _link(conn, src, e_gem) + # the observation itself gets NO direct unit_entities row + await _mk_unit(conn, bank_id, "Synthesised: the Northstar lane.", "observation", sources=[src]) + + assert ( + await any_memory_updated_since( + conn=conn, + fq_table=fq_table, + bank_id=bank_id, + since=since, + fact_types=["observation"], + tag_groups=_groups({"entities": ["northstar"]}), + ) + is True + ) + # entity exists in the bank, but no observation reaches it + e_other = await _mk_entity(conn, bank_id, "Docker") + _ = e_other + assert ( + await any_memory_updated_since( + conn=conn, + fq_table=fq_table, + bank_id=bank_id, + since=since, + fact_types=["observation"], + tag_groups=_groups({"entities": ["docker"]}), + ) + is False + ) + + +@pytest.mark.asyncio +async def test_staleness_entity_and_tag_conjunction(memory: MemoryEngine): + """Mixed group: entity leaf AND tag leaf must both hold on the same row.""" + bank_id = f"test-ent-stale-{uuid.uuid4().hex[:8]}" + since = datetime.now(timezone.utc) - timedelta(minutes=5) + async with memory._pool.acquire() as conn: + e_atlas = await _mk_entity(conn, bank_id, "atlas") + tagged = str(uuid.uuid4()) + await conn.execute( + """ + INSERT INTO memory_units (id, bank_id, text, fact_type, tags, created_at, updated_at) + VALUES ($1::uuid, $2, 'atlas under infra tag', 'world', ARRAY['topic:infra'], now(), now()) + """, + tagged, + bank_id, + ) + await _link(conn, tagged, e_atlas) + + both = _groups({"and": [{"tags": ["topic:infra"], "match": "any_strict"}, {"entities": ["atlas"]}]}) + wrong_tag = _groups({"and": [{"tags": ["topic:vendors"], "match": "any_strict"}, {"entities": ["atlas"]}]}) + assert ( + await any_memory_updated_since(conn=conn, fq_table=fq_table, bank_id=bank_id, since=since, tag_groups=both) + is True + ) + assert ( + await any_memory_updated_since( + conn=conn, fq_table=fq_table, bank_id=bank_id, since=since, tag_groups=wrong_tag + ) + is False + ) + + +@pytest.mark.asyncio +async def test_staleness_all_match_requires_every_entity(memory: MemoryEngine): + bank_id = f"test-ent-stale-{uuid.uuid4().hex[:8]}" + since = datetime.now(timezone.utc) - timedelta(minutes=5) + async with memory._pool.acquire() as conn: + e_atlas = await _mk_entity(conn, bank_id, "atlas") + e_gem = await _mk_entity(conn, bank_id, "Northstar") + unit = await _mk_unit(conn, bank_id, "atlas warms Northstar.") + await _link(conn, unit, e_atlas) + await _link(conn, unit, e_gem) + only_atlas = await _mk_unit(conn, bank_id, "atlas alone.") + await _link(conn, only_atlas, e_atlas) + + assert ( + await any_memory_updated_since( + conn=conn, + fq_table=fq_table, + bank_id=bank_id, + since=since, + tag_groups=_groups({"entities": ["atlas", "northstar"], "match": "all"}), + ) + is True + ) + assert ( + await any_memory_updated_since( + conn=conn, + fq_table=fq_table, + bank_id=bank_id, + since=since, + tag_groups=_groups({"entities": ["atlas", "docker"], "match": "all"}), + ) + is False + ) + + +@pytest.mark.asyncio +async def test_cross_bank_entity_names_do_not_leak(memory: MemoryEngine): + """A same-named entity in another bank has a different id and no link to this + bank's units -- the join must not cross banks.""" + bank_a = f"test-ent-a-{uuid.uuid4().hex[:8]}" + bank_b = f"test-ent-b-{uuid.uuid4().hex[:8]}" + since = datetime.now(timezone.utc) - timedelta(minutes=5) + async with memory._pool.acquire() as conn: + e_b = await _mk_entity(conn, bank_b, "atlas") + unit_b = await _mk_unit(conn, bank_b, "bank-b atlas fact.") + await _link(conn, unit_b, e_b) + # bank A has a recent unit but no atlas entity/link + await _mk_unit(conn, bank_a, "unrelated fact.") + + assert ( + await any_memory_updated_since( + conn=conn, + fq_table=fq_table, + bank_id=bank_a, + since=since, + tag_groups=_groups({"entities": ["atlas"]}), + ) + is False + ) diff --git a/hindsight-cli/src/commands/knowledge_base.rs b/hindsight-cli/src/commands/knowledge_base.rs index 4685947932..f0545e6be1 100644 --- a/hindsight-cli/src/commands/knowledge_base.rs +++ b/hindsight-cli/src/commands/knowledge_base.rs @@ -157,6 +157,7 @@ pub fn create_page( include_chunks: None, recall_max_tokens: None, recall_chunks_max_tokens: None, + delta_fast_path: None, response_schema: None, keep_trace: false, }) diff --git a/hindsight-cli/src/commands/mental_model.rs b/hindsight-cli/src/commands/mental_model.rs index 57ed274d8d..08b53d5c51 100644 --- a/hindsight-cli/src/commands/mental_model.rs +++ b/hindsight-cli/src/commands/mental_model.rs @@ -148,6 +148,7 @@ pub fn create( include_chunks: None, recall_max_tokens: None, recall_chunks_max_tokens: None, + delta_fast_path: None, response_schema: None, keep_trace: false, }) @@ -229,6 +230,7 @@ pub fn update( include_chunks: None, recall_max_tokens: None, recall_chunks_max_tokens: None, + delta_fast_path: None, response_schema: None, keep_trace: false, }); diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index 7ddb58d5d4..6d9a199f5e 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -5892,6 +5892,7 @@ components: tags: - tags - tags + delta_fast_path: true exclude_mental_models: false mode: full refresh_after_consolidation: false @@ -7998,6 +7999,21 @@ components: - refresh_failed_delta_not_applied title: Outcome type: string + fast_path: + enum: + - tier0 + - tier1 + nullable: true + type: string + fast_path_fallback_reason: + enum: + - no_delta_baseline + - needs_full_context + - delta_ops_failed + - delta_ops_invalid + - delta_ops_all_skipped + nullable: true + type: string would_persist: description: Whether a real refresh would write new content. title: Would Persist @@ -8022,7 +8038,12 @@ components: title: Current Content type: string candidate_content: - description: "Raw reflect synthesis, before any delta operations." + description: "The document the run's synthesis step produced, before any\ + \ delta operations: the raw reflect answer when the agentic loop ran.\ + \ The delta fast path has no synthesis step, so it reports what it would\ + \ write instead — the current content on tier 0 (nothing new was found),\ + \ and the post-operation document on tier 1 (identical to preview_content).\ + \ Compare against preview_content to see what the delta changed." title: Candidate Content type: string preview_content: @@ -8111,6 +8132,7 @@ components: tags: - tags - tags + delta_fast_path: true exclude_mental_models: false mode: full refresh_after_consolidation: false @@ -8152,6 +8174,7 @@ components: tags: - tags - tags + delta_fast_path: true exclude_mental_models: false mode: full refresh_after_consolidation: false @@ -8268,6 +8291,21 @@ components: - refresh_failed_delta_not_applied title: Outcome type: string + fast_path: + enum: + - tier0 + - tier1 + nullable: true + type: string + fast_path_fallback_reason: + enum: + - no_delta_baseline + - needs_full_context + - delta_ops_failed + - delta_ops_invalid + - delta_ops_all_skipped + nullable: true + type: string tool_calls: description: Reflect tool calls made during the refresh. items: @@ -8340,6 +8378,7 @@ components: tags: - tags - tags + delta_fast_path: true exclude_mental_models: false mode: full refresh_after_consolidation: false @@ -8463,6 +8502,7 @@ components: tags: - tags - tags + delta_fast_path: true exclude_mental_models: false mode: full refresh_after_consolidation: false @@ -8544,6 +8584,9 @@ components: recall_chunks_max_tokens: nullable: true type: integer + delta_fast_path: + nullable: true + type: boolean response_schema: additionalProperties: {} nullable: true @@ -8575,6 +8618,7 @@ components: tags: - tags - tags + delta_fast_path: true exclude_mental_models: false mode: full refresh_after_consolidation: false @@ -8656,6 +8700,9 @@ components: recall_chunks_max_tokens: nullable: true type: integer + delta_fast_path: + nullable: true + type: boolean response_schema: additionalProperties: {} nullable: true @@ -9640,6 +9687,53 @@ components: required: - and title: TagGroupAnd + TagGroupEntityLeaf: + description: |- + A leaf ENTITY filter: matches memories by the entities they mention. + + Tags describe which *compartment* a memory lives in; entities describe what it + is *about*. On a bank whose tag vocabulary is a handful of broad topics, a tag + scope cannot isolate a subject — measured on a production bank (2026-08-08), + the best tag scope for one subject reached 25% precision against a 15.5% base + rate, while the entity association reached 94.2% precision / 86.2% recall on + the same corpus. This leaf makes that association usable anywhere a + ``TagGroup`` already is: mental-model refresh scope, the staleness gate, and + retrieval filtering, through the same recursive grammar. + + ``entities`` are canonical names, matched case-insensitively — the same + normalisation the entity registry itself enforces via its + ``(bank_id, LOWER(canonical_name))`` uniqueness. + + Association is inheritance-aware: a memory matches if it links the entity + directly (``unit_entities``) or through any of its ``source_memory_ids`` — the + lane observations use, since consolidation-produced observations carry no + direct postings by design (their entity association is transitive through + their sources; see ``memories/pg/graph.py:_entity_rows_for_units_sql``). + + ``match="any"``: mentions at least one listed entity. ``match="all"``: mentions + every listed entity (directly or via sources, per entity). + + Constraints, enforced by ``validate_entity_leaf_placement`` at the API edge: + an entity leaf may not appear under ``not`` — the two permissive fallbacks + (the Python-side post-filter and the non-memory_units surfaces that strip + entity leaves) evaluate an unknown entity constraint as "matches", and a NOT + over a permissive "matches" silently inverts into "exclude everything". + properties: + entities: + items: + type: string + minItems: 1 + type: array + match: + default: any + enum: + - any + - all + title: EntityMatch + type: string + required: + - entities + title: TagGroupEntityLeaf TagGroupLeaf: description: "A leaf tag filter: matches memories by tag list and match mode." example: @@ -10308,18 +10402,21 @@ components: MentalModelRefreshScope_tag_groups_inner: anyOf: - $ref: '#/components/schemas/TagGroupLeaf' + - $ref: '#/components/schemas/TagGroupEntityLeaf' - $ref: '#/components/schemas/TagGroupAnd-Output' - $ref: '#/components/schemas/TagGroupOr-Output' - $ref: '#/components/schemas/TagGroupNot-Output' MentalModelTrigger_Input_tag_groups_inner: anyOf: - $ref: '#/components/schemas/TagGroupLeaf' + - $ref: '#/components/schemas/TagGroupEntityLeaf' - $ref: '#/components/schemas/TagGroupAnd-Input' - $ref: '#/components/schemas/TagGroupOr-Input' - $ref: '#/components/schemas/TagGroupNot-Input' Not: anyOf: - $ref: '#/components/schemas/TagGroupLeaf' + - $ref: '#/components/schemas/TagGroupEntityLeaf' - $ref: '#/components/schemas/TagGroupAnd-Input' - $ref: '#/components/schemas/TagGroupOr-Input' - $ref: '#/components/schemas/TagGroupNot-Input' @@ -10327,6 +10424,7 @@ components: Not_1: anyOf: - $ref: '#/components/schemas/TagGroupLeaf' + - $ref: '#/components/schemas/TagGroupEntityLeaf' - $ref: '#/components/schemas/TagGroupAnd-Output' - $ref: '#/components/schemas/TagGroupOr-Output' - $ref: '#/components/schemas/TagGroupNot-Output' diff --git a/hindsight-clients/go/model_mental_model_dry_run_refresh_result.go b/hindsight-clients/go/model_mental_model_dry_run_refresh_result.go index 3a5d40d18a..62753e1e67 100644 --- a/hindsight-clients/go/model_mental_model_dry_run_refresh_result.go +++ b/hindsight-clients/go/model_mental_model_dry_run_refresh_result.go @@ -32,6 +32,8 @@ type MentalModelDryRunRefreshResult struct { ModeFallbackReason NullableString `json:"mode_fallback_reason,omitempty"` // What a real refresh would do with the document. Outcome string `json:"outcome"` + FastPath NullableString `json:"fast_path,omitempty"` + FastPathFallbackReason NullableString `json:"fast_path_fallback_reason,omitempty"` // Whether a real refresh would write new content. WouldPersist bool `json:"would_persist"` // The resolved memory scope. @@ -44,7 +46,7 @@ type MentalModelDryRunRefreshResult struct { BasedOn map[string][]map[string]interface{} `json:"based_on,omitempty"` // The model's content as it stands now. CurrentContent string `json:"current_content"` - // Raw reflect synthesis, before any delta operations. + // The document the run's synthesis step produced, before any delta operations: the raw reflect answer when the agentic loop ran. The delta fast path has no synthesis step, so it reports what it would write instead — the current content on tier 0 (nothing new was found), and the post-operation document on tier 1 (identical to preview_content). Compare against preview_content to see what the delta changed. CandidateContent string `json:"candidate_content"` // The content a real refresh would store: the delta-edited document, or the candidate in full mode. PreviewContent string `json:"preview_content"` @@ -260,6 +262,90 @@ func (o *MentalModelDryRunRefreshResult) SetOutcome(v string) { o.Outcome = v } +// GetFastPath returns the FastPath field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *MentalModelDryRunRefreshResult) GetFastPath() string { + if o == nil || IsNil(o.FastPath.Get()) { + var ret string + return ret + } + return *o.FastPath.Get() +} + +// GetFastPathOk returns a tuple with the FastPath field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *MentalModelDryRunRefreshResult) GetFastPathOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.FastPath.Get(), o.FastPath.IsSet() +} + +// HasFastPath returns a boolean if a field has been set. +func (o *MentalModelDryRunRefreshResult) HasFastPath() bool { + if o != nil && o.FastPath.IsSet() { + return true + } + + return false +} + +// SetFastPath gets a reference to the given NullableString and assigns it to the FastPath field. +func (o *MentalModelDryRunRefreshResult) SetFastPath(v string) { + o.FastPath.Set(&v) +} +// SetFastPathNil sets the value for FastPath to be an explicit nil +func (o *MentalModelDryRunRefreshResult) SetFastPathNil() { + o.FastPath.Set(nil) +} + +// UnsetFastPath ensures that no value is present for FastPath, not even an explicit nil +func (o *MentalModelDryRunRefreshResult) UnsetFastPath() { + o.FastPath.Unset() +} + +// GetFastPathFallbackReason returns the FastPathFallbackReason field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *MentalModelDryRunRefreshResult) GetFastPathFallbackReason() string { + if o == nil || IsNil(o.FastPathFallbackReason.Get()) { + var ret string + return ret + } + return *o.FastPathFallbackReason.Get() +} + +// GetFastPathFallbackReasonOk returns a tuple with the FastPathFallbackReason field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *MentalModelDryRunRefreshResult) GetFastPathFallbackReasonOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.FastPathFallbackReason.Get(), o.FastPathFallbackReason.IsSet() +} + +// HasFastPathFallbackReason returns a boolean if a field has been set. +func (o *MentalModelDryRunRefreshResult) HasFastPathFallbackReason() bool { + if o != nil && o.FastPathFallbackReason.IsSet() { + return true + } + + return false +} + +// SetFastPathFallbackReason gets a reference to the given NullableString and assigns it to the FastPathFallbackReason field. +func (o *MentalModelDryRunRefreshResult) SetFastPathFallbackReason(v string) { + o.FastPathFallbackReason.Set(&v) +} +// SetFastPathFallbackReasonNil sets the value for FastPathFallbackReason to be an explicit nil +func (o *MentalModelDryRunRefreshResult) SetFastPathFallbackReasonNil() { + o.FastPathFallbackReason.Set(nil) +} + +// UnsetFastPathFallbackReason ensures that no value is present for FastPathFallbackReason, not even an explicit nil +func (o *MentalModelDryRunRefreshResult) UnsetFastPathFallbackReason() { + o.FastPathFallbackReason.Unset() +} + // GetWouldPersist returns the WouldPersist field value func (o *MentalModelDryRunRefreshResult) GetWouldPersist() bool { if o == nil { @@ -664,6 +750,12 @@ func (o MentalModelDryRunRefreshResult) ToMap() (map[string]interface{}, error) toSerialize["mode_fallback_reason"] = o.ModeFallbackReason.Get() } toSerialize["outcome"] = o.Outcome + if o.FastPath.IsSet() { + toSerialize["fast_path"] = o.FastPath.Get() + } + if o.FastPathFallbackReason.IsSet() { + toSerialize["fast_path_fallback_reason"] = o.FastPathFallbackReason.Get() + } toSerialize["would_persist"] = o.WouldPersist toSerialize["scope"] = o.Scope toSerialize["window"] = o.Window diff --git a/hindsight-clients/go/model_mental_model_refresh_scope_tag_groups_inner.go b/hindsight-clients/go/model_mental_model_refresh_scope_tag_groups_inner.go index 96d6a8bc2b..224556ee63 100644 --- a/hindsight-clients/go/model_mental_model_refresh_scope_tag_groups_inner.go +++ b/hindsight-clients/go/model_mental_model_refresh_scope_tag_groups_inner.go @@ -19,6 +19,7 @@ import ( // MentalModelRefreshScopeTagGroupsInner struct for MentalModelRefreshScopeTagGroupsInner type MentalModelRefreshScopeTagGroupsInner struct { TagGroupAndOutput *TagGroupAndOutput + TagGroupEntityLeaf *TagGroupEntityLeaf TagGroupLeaf *TagGroupLeaf TagGroupNotOutput *TagGroupNotOutput TagGroupOrOutput *TagGroupOrOutput @@ -40,6 +41,19 @@ func (dst *MentalModelRefreshScopeTagGroupsInner) UnmarshalJSON(data []byte) err dst.TagGroupAndOutput = nil } + // try to unmarshal JSON data into TagGroupEntityLeaf + err = json.Unmarshal(data, &dst.TagGroupEntityLeaf); + if err == nil { + jsonTagGroupEntityLeaf, _ := json.Marshal(dst.TagGroupEntityLeaf) + if string(jsonTagGroupEntityLeaf) == "{}" { // empty struct + dst.TagGroupEntityLeaf = nil + } else { + return nil // data stored in dst.TagGroupEntityLeaf, return on the first match + } + } else { + dst.TagGroupEntityLeaf = nil + } + // try to unmarshal JSON data into TagGroupLeaf err = json.Unmarshal(data, &dst.TagGroupLeaf); if err == nil { @@ -88,6 +102,10 @@ func (src *MentalModelRefreshScopeTagGroupsInner) MarshalJSON() ([]byte, error) return json.Marshal(&src.TagGroupAndOutput) } + if src.TagGroupEntityLeaf != nil { + return json.Marshal(&src.TagGroupEntityLeaf) + } + if src.TagGroupLeaf != nil { return json.Marshal(&src.TagGroupLeaf) } diff --git a/hindsight-clients/go/model_mental_model_refresh_trace.go b/hindsight-clients/go/model_mental_model_refresh_trace.go index 3bb9f26900..de8fa9a2d3 100644 --- a/hindsight-clients/go/model_mental_model_refresh_trace.go +++ b/hindsight-clients/go/model_mental_model_refresh_trace.go @@ -28,6 +28,8 @@ type MentalModelRefreshTrace struct { ModeFallbackReason NullableString `json:"mode_fallback_reason,omitempty"` // What the refresh did with the document. Outcome string `json:"outcome"` + FastPath NullableString `json:"fast_path,omitempty"` + FastPathFallbackReason NullableString `json:"fast_path_fallback_reason,omitempty"` // Reflect tool calls made during the refresh. ToolCalls []MentalModelTraceToolCall `json:"tool_calls,omitempty"` // LLM calls made during the refresh. @@ -197,6 +199,90 @@ func (o *MentalModelRefreshTrace) SetOutcome(v string) { o.Outcome = v } +// GetFastPath returns the FastPath field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *MentalModelRefreshTrace) GetFastPath() string { + if o == nil || IsNil(o.FastPath.Get()) { + var ret string + return ret + } + return *o.FastPath.Get() +} + +// GetFastPathOk returns a tuple with the FastPath field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *MentalModelRefreshTrace) GetFastPathOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.FastPath.Get(), o.FastPath.IsSet() +} + +// HasFastPath returns a boolean if a field has been set. +func (o *MentalModelRefreshTrace) HasFastPath() bool { + if o != nil && o.FastPath.IsSet() { + return true + } + + return false +} + +// SetFastPath gets a reference to the given NullableString and assigns it to the FastPath field. +func (o *MentalModelRefreshTrace) SetFastPath(v string) { + o.FastPath.Set(&v) +} +// SetFastPathNil sets the value for FastPath to be an explicit nil +func (o *MentalModelRefreshTrace) SetFastPathNil() { + o.FastPath.Set(nil) +} + +// UnsetFastPath ensures that no value is present for FastPath, not even an explicit nil +func (o *MentalModelRefreshTrace) UnsetFastPath() { + o.FastPath.Unset() +} + +// GetFastPathFallbackReason returns the FastPathFallbackReason field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *MentalModelRefreshTrace) GetFastPathFallbackReason() string { + if o == nil || IsNil(o.FastPathFallbackReason.Get()) { + var ret string + return ret + } + return *o.FastPathFallbackReason.Get() +} + +// GetFastPathFallbackReasonOk returns a tuple with the FastPathFallbackReason field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *MentalModelRefreshTrace) GetFastPathFallbackReasonOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.FastPathFallbackReason.Get(), o.FastPathFallbackReason.IsSet() +} + +// HasFastPathFallbackReason returns a boolean if a field has been set. +func (o *MentalModelRefreshTrace) HasFastPathFallbackReason() bool { + if o != nil && o.FastPathFallbackReason.IsSet() { + return true + } + + return false +} + +// SetFastPathFallbackReason gets a reference to the given NullableString and assigns it to the FastPathFallbackReason field. +func (o *MentalModelRefreshTrace) SetFastPathFallbackReason(v string) { + o.FastPathFallbackReason.Set(&v) +} +// SetFastPathFallbackReasonNil sets the value for FastPathFallbackReason to be an explicit nil +func (o *MentalModelRefreshTrace) SetFastPathFallbackReasonNil() { + o.FastPathFallbackReason.Set(nil) +} + +// UnsetFastPathFallbackReason ensures that no value is present for FastPathFallbackReason, not even an explicit nil +func (o *MentalModelRefreshTrace) UnsetFastPathFallbackReason() { + o.FastPathFallbackReason.Unset() +} + // GetToolCalls returns the ToolCalls field value if set, zero value otherwise. func (o *MentalModelRefreshTrace) GetToolCalls() []MentalModelTraceToolCall { if o == nil || IsNil(o.ToolCalls) { @@ -427,6 +513,12 @@ func (o MentalModelRefreshTrace) ToMap() (map[string]interface{}, error) { toSerialize["mode_fallback_reason"] = o.ModeFallbackReason.Get() } toSerialize["outcome"] = o.Outcome + if o.FastPath.IsSet() { + toSerialize["fast_path"] = o.FastPath.Get() + } + if o.FastPathFallbackReason.IsSet() { + toSerialize["fast_path_fallback_reason"] = o.FastPathFallbackReason.Get() + } if !IsNil(o.ToolCalls) { toSerialize["tool_calls"] = o.ToolCalls } diff --git a/hindsight-clients/go/model_mental_model_trigger_input.go b/hindsight-clients/go/model_mental_model_trigger_input.go index 4046d00fd6..a19e236703 100644 --- a/hindsight-clients/go/model_mental_model_trigger_input.go +++ b/hindsight-clients/go/model_mental_model_trigger_input.go @@ -33,6 +33,7 @@ type MentalModelTriggerInput struct { IncludeChunks NullableBool `json:"include_chunks,omitempty"` RecallMaxTokens NullableInt32 `json:"recall_max_tokens,omitempty"` RecallChunksMaxTokens NullableInt32 `json:"recall_chunks_max_tokens,omitempty"` + DeltaFastPath NullableBool `json:"delta_fast_path,omitempty"` ResponseSchema map[string]interface{} `json:"response_schema,omitempty"` // If true, every refresh of this mental model records how it reached its result under reflect_response.trace: the mode it ran in and why, the resolved scope and time window, how many facts retrieval returned versus how many the agent used, the tool and LLM calls, and any delta operations. Only the latest refresh's trace is kept. This is the only way to diagnose a cron- or consolidation-driven refresh after the fact, since no human sees those run. Tool outputs are reduced to result counts to keep the stored trace bounded; use LLM request tracing for raw prompts and responses. KeepTrace *bool `json:"keep_trace,omitempty"` @@ -476,6 +477,48 @@ func (o *MentalModelTriggerInput) UnsetRecallChunksMaxTokens() { o.RecallChunksMaxTokens.Unset() } +// GetDeltaFastPath returns the DeltaFastPath field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *MentalModelTriggerInput) GetDeltaFastPath() bool { + if o == nil || IsNil(o.DeltaFastPath.Get()) { + var ret bool + return ret + } + return *o.DeltaFastPath.Get() +} + +// GetDeltaFastPathOk returns a tuple with the DeltaFastPath field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *MentalModelTriggerInput) GetDeltaFastPathOk() (*bool, bool) { + if o == nil { + return nil, false + } + return o.DeltaFastPath.Get(), o.DeltaFastPath.IsSet() +} + +// HasDeltaFastPath returns a boolean if a field has been set. +func (o *MentalModelTriggerInput) HasDeltaFastPath() bool { + if o != nil && o.DeltaFastPath.IsSet() { + return true + } + + return false +} + +// SetDeltaFastPath gets a reference to the given NullableBool and assigns it to the DeltaFastPath field. +func (o *MentalModelTriggerInput) SetDeltaFastPath(v bool) { + o.DeltaFastPath.Set(&v) +} +// SetDeltaFastPathNil sets the value for DeltaFastPath to be an explicit nil +func (o *MentalModelTriggerInput) SetDeltaFastPathNil() { + o.DeltaFastPath.Set(nil) +} + +// UnsetDeltaFastPath ensures that no value is present for DeltaFastPath, not even an explicit nil +func (o *MentalModelTriggerInput) UnsetDeltaFastPath() { + o.DeltaFastPath.Unset() +} + // GetResponseSchema returns the ResponseSchema field value if set, zero value otherwise (both if not set or set to explicit null). func (o *MentalModelTriggerInput) GetResponseSchema() map[string]interface{} { if o == nil { @@ -584,6 +627,9 @@ func (o MentalModelTriggerInput) ToMap() (map[string]interface{}, error) { if o.RecallChunksMaxTokens.IsSet() { toSerialize["recall_chunks_max_tokens"] = o.RecallChunksMaxTokens.Get() } + if o.DeltaFastPath.IsSet() { + toSerialize["delta_fast_path"] = o.DeltaFastPath.Get() + } if o.ResponseSchema != nil { toSerialize["response_schema"] = o.ResponseSchema } diff --git a/hindsight-clients/go/model_mental_model_trigger_input_tag_groups_inner.go b/hindsight-clients/go/model_mental_model_trigger_input_tag_groups_inner.go index 0bae4b43ad..b6740c6b9d 100644 --- a/hindsight-clients/go/model_mental_model_trigger_input_tag_groups_inner.go +++ b/hindsight-clients/go/model_mental_model_trigger_input_tag_groups_inner.go @@ -19,6 +19,7 @@ import ( // MentalModelTriggerInputTagGroupsInner struct for MentalModelTriggerInputTagGroupsInner type MentalModelTriggerInputTagGroupsInner struct { TagGroupAndInput *TagGroupAndInput + TagGroupEntityLeaf *TagGroupEntityLeaf TagGroupLeaf *TagGroupLeaf TagGroupNotInput *TagGroupNotInput TagGroupOrInput *TagGroupOrInput @@ -40,6 +41,19 @@ func (dst *MentalModelTriggerInputTagGroupsInner) UnmarshalJSON(data []byte) err dst.TagGroupAndInput = nil } + // try to unmarshal JSON data into TagGroupEntityLeaf + err = json.Unmarshal(data, &dst.TagGroupEntityLeaf); + if err == nil { + jsonTagGroupEntityLeaf, _ := json.Marshal(dst.TagGroupEntityLeaf) + if string(jsonTagGroupEntityLeaf) == "{}" { // empty struct + dst.TagGroupEntityLeaf = nil + } else { + return nil // data stored in dst.TagGroupEntityLeaf, return on the first match + } + } else { + dst.TagGroupEntityLeaf = nil + } + // try to unmarshal JSON data into TagGroupLeaf err = json.Unmarshal(data, &dst.TagGroupLeaf); if err == nil { @@ -88,6 +102,10 @@ func (src *MentalModelTriggerInputTagGroupsInner) MarshalJSON() ([]byte, error) return json.Marshal(&src.TagGroupAndInput) } + if src.TagGroupEntityLeaf != nil { + return json.Marshal(&src.TagGroupEntityLeaf) + } + if src.TagGroupLeaf != nil { return json.Marshal(&src.TagGroupLeaf) } diff --git a/hindsight-clients/go/model_mental_model_trigger_output.go b/hindsight-clients/go/model_mental_model_trigger_output.go index 2a8fef03a5..d81c267961 100644 --- a/hindsight-clients/go/model_mental_model_trigger_output.go +++ b/hindsight-clients/go/model_mental_model_trigger_output.go @@ -33,6 +33,7 @@ type MentalModelTriggerOutput struct { IncludeChunks NullableBool `json:"include_chunks,omitempty"` RecallMaxTokens NullableInt32 `json:"recall_max_tokens,omitempty"` RecallChunksMaxTokens NullableInt32 `json:"recall_chunks_max_tokens,omitempty"` + DeltaFastPath NullableBool `json:"delta_fast_path,omitempty"` ResponseSchema map[string]interface{} `json:"response_schema,omitempty"` // If true, every refresh of this mental model records how it reached its result under reflect_response.trace: the mode it ran in and why, the resolved scope and time window, how many facts retrieval returned versus how many the agent used, the tool and LLM calls, and any delta operations. Only the latest refresh's trace is kept. This is the only way to diagnose a cron- or consolidation-driven refresh after the fact, since no human sees those run. Tool outputs are reduced to result counts to keep the stored trace bounded; use LLM request tracing for raw prompts and responses. KeepTrace *bool `json:"keep_trace,omitempty"` @@ -476,6 +477,48 @@ func (o *MentalModelTriggerOutput) UnsetRecallChunksMaxTokens() { o.RecallChunksMaxTokens.Unset() } +// GetDeltaFastPath returns the DeltaFastPath field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *MentalModelTriggerOutput) GetDeltaFastPath() bool { + if o == nil || IsNil(o.DeltaFastPath.Get()) { + var ret bool + return ret + } + return *o.DeltaFastPath.Get() +} + +// GetDeltaFastPathOk returns a tuple with the DeltaFastPath field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *MentalModelTriggerOutput) GetDeltaFastPathOk() (*bool, bool) { + if o == nil { + return nil, false + } + return o.DeltaFastPath.Get(), o.DeltaFastPath.IsSet() +} + +// HasDeltaFastPath returns a boolean if a field has been set. +func (o *MentalModelTriggerOutput) HasDeltaFastPath() bool { + if o != nil && o.DeltaFastPath.IsSet() { + return true + } + + return false +} + +// SetDeltaFastPath gets a reference to the given NullableBool and assigns it to the DeltaFastPath field. +func (o *MentalModelTriggerOutput) SetDeltaFastPath(v bool) { + o.DeltaFastPath.Set(&v) +} +// SetDeltaFastPathNil sets the value for DeltaFastPath to be an explicit nil +func (o *MentalModelTriggerOutput) SetDeltaFastPathNil() { + o.DeltaFastPath.Set(nil) +} + +// UnsetDeltaFastPath ensures that no value is present for DeltaFastPath, not even an explicit nil +func (o *MentalModelTriggerOutput) UnsetDeltaFastPath() { + o.DeltaFastPath.Unset() +} + // GetResponseSchema returns the ResponseSchema field value if set, zero value otherwise (both if not set or set to explicit null). func (o *MentalModelTriggerOutput) GetResponseSchema() map[string]interface{} { if o == nil { @@ -584,6 +627,9 @@ func (o MentalModelTriggerOutput) ToMap() (map[string]interface{}, error) { if o.RecallChunksMaxTokens.IsSet() { toSerialize["recall_chunks_max_tokens"] = o.RecallChunksMaxTokens.Get() } + if o.DeltaFastPath.IsSet() { + toSerialize["delta_fast_path"] = o.DeltaFastPath.Get() + } if o.ResponseSchema != nil { toSerialize["response_schema"] = o.ResponseSchema } diff --git a/hindsight-clients/go/model_not.go b/hindsight-clients/go/model_not.go index 3d9824b2b5..3e8953a7aa 100644 --- a/hindsight-clients/go/model_not.go +++ b/hindsight-clients/go/model_not.go @@ -19,6 +19,7 @@ import ( // Not struct for Not type Not struct { TagGroupAndInput *TagGroupAndInput + TagGroupEntityLeaf *TagGroupEntityLeaf TagGroupLeaf *TagGroupLeaf TagGroupNotInput *TagGroupNotInput TagGroupOrInput *TagGroupOrInput @@ -40,6 +41,19 @@ func (dst *Not) UnmarshalJSON(data []byte) error { dst.TagGroupAndInput = nil } + // try to unmarshal JSON data into TagGroupEntityLeaf + err = json.Unmarshal(data, &dst.TagGroupEntityLeaf); + if err == nil { + jsonTagGroupEntityLeaf, _ := json.Marshal(dst.TagGroupEntityLeaf) + if string(jsonTagGroupEntityLeaf) == "{}" { // empty struct + dst.TagGroupEntityLeaf = nil + } else { + return nil // data stored in dst.TagGroupEntityLeaf, return on the first match + } + } else { + dst.TagGroupEntityLeaf = nil + } + // try to unmarshal JSON data into TagGroupLeaf err = json.Unmarshal(data, &dst.TagGroupLeaf); if err == nil { @@ -88,6 +102,10 @@ func (src *Not) MarshalJSON() ([]byte, error) { return json.Marshal(&src.TagGroupAndInput) } + if src.TagGroupEntityLeaf != nil { + return json.Marshal(&src.TagGroupEntityLeaf) + } + if src.TagGroupLeaf != nil { return json.Marshal(&src.TagGroupLeaf) } diff --git a/hindsight-clients/go/model_not_1.go b/hindsight-clients/go/model_not_1.go index 9ae7116b83..3f02c1ef7a 100644 --- a/hindsight-clients/go/model_not_1.go +++ b/hindsight-clients/go/model_not_1.go @@ -19,6 +19,7 @@ import ( // Not1 struct for Not1 type Not1 struct { TagGroupAndOutput *TagGroupAndOutput + TagGroupEntityLeaf *TagGroupEntityLeaf TagGroupLeaf *TagGroupLeaf TagGroupNotOutput *TagGroupNotOutput TagGroupOrOutput *TagGroupOrOutput @@ -40,6 +41,19 @@ func (dst *Not1) UnmarshalJSON(data []byte) error { dst.TagGroupAndOutput = nil } + // try to unmarshal JSON data into TagGroupEntityLeaf + err = json.Unmarshal(data, &dst.TagGroupEntityLeaf); + if err == nil { + jsonTagGroupEntityLeaf, _ := json.Marshal(dst.TagGroupEntityLeaf) + if string(jsonTagGroupEntityLeaf) == "{}" { // empty struct + dst.TagGroupEntityLeaf = nil + } else { + return nil // data stored in dst.TagGroupEntityLeaf, return on the first match + } + } else { + dst.TagGroupEntityLeaf = nil + } + // try to unmarshal JSON data into TagGroupLeaf err = json.Unmarshal(data, &dst.TagGroupLeaf); if err == nil { @@ -88,6 +102,10 @@ func (src *Not1) MarshalJSON() ([]byte, error) { return json.Marshal(&src.TagGroupAndOutput) } + if src.TagGroupEntityLeaf != nil { + return json.Marshal(&src.TagGroupEntityLeaf) + } + if src.TagGroupLeaf != nil { return json.Marshal(&src.TagGroupLeaf) } diff --git a/hindsight-clients/go/model_tag_group_entity_leaf.go b/hindsight-clients/go/model_tag_group_entity_leaf.go new file mode 100644 index 0000000000..749a869545 --- /dev/null +++ b/hindsight-clients/go/model_tag_group_entity_leaf.go @@ -0,0 +1,198 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.9.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" + "bytes" + "fmt" +) + +// checks if the TagGroupEntityLeaf type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &TagGroupEntityLeaf{} + +// TagGroupEntityLeaf A leaf ENTITY filter: matches memories by the entities they mention. Tags describe which *compartment* a memory lives in; entities describe what it is *about*. On a bank whose tag vocabulary is a handful of broad topics, a tag scope cannot isolate a subject — measured on a production bank (2026-08-08), the best tag scope for one subject reached 25% precision against a 15.5% base rate, while the entity association reached 94.2% precision / 86.2% recall on the same corpus. This leaf makes that association usable anywhere a ``TagGroup`` already is: mental-model refresh scope, the staleness gate, and retrieval filtering, through the same recursive grammar. ``entities`` are canonical names, matched case-insensitively — the same normalisation the entity registry itself enforces via its ``(bank_id, LOWER(canonical_name))`` uniqueness. Association is inheritance-aware: a memory matches if it links the entity directly (``unit_entities``) or through any of its ``source_memory_ids`` — the lane observations use, since consolidation-produced observations carry no direct postings by design (their entity association is transitive through their sources; see ``memories/pg/graph.py:_entity_rows_for_units_sql``). ``match=\"any\"``: mentions at least one listed entity. ``match=\"all\"``: mentions every listed entity (directly or via sources, per entity). Constraints, enforced by ``validate_entity_leaf_placement`` at the API edge: an entity leaf may not appear under ``not`` — the two permissive fallbacks (the Python-side post-filter and the non-memory_units surfaces that strip entity leaves) evaluate an unknown entity constraint as \"matches\", and a NOT over a permissive \"matches\" silently inverts into \"exclude everything\". +type TagGroupEntityLeaf struct { + Entities []string `json:"entities"` + Match *string `json:"match,omitempty"` +} + +type _TagGroupEntityLeaf TagGroupEntityLeaf + +// NewTagGroupEntityLeaf instantiates a new TagGroupEntityLeaf object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewTagGroupEntityLeaf(entities []string) *TagGroupEntityLeaf { + this := TagGroupEntityLeaf{} + this.Entities = entities + var match string = "any" + this.Match = &match + return &this +} + +// NewTagGroupEntityLeafWithDefaults instantiates a new TagGroupEntityLeaf object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewTagGroupEntityLeafWithDefaults() *TagGroupEntityLeaf { + this := TagGroupEntityLeaf{} + var match string = "any" + this.Match = &match + return &this +} + +// GetEntities returns the Entities field value +func (o *TagGroupEntityLeaf) GetEntities() []string { + if o == nil { + var ret []string + return ret + } + + return o.Entities +} + +// GetEntitiesOk returns a tuple with the Entities field value +// and a boolean to check if the value has been set. +func (o *TagGroupEntityLeaf) GetEntitiesOk() ([]string, bool) { + if o == nil { + return nil, false + } + return o.Entities, true +} + +// SetEntities sets field value +func (o *TagGroupEntityLeaf) SetEntities(v []string) { + o.Entities = v +} + +// GetMatch returns the Match field value if set, zero value otherwise. +func (o *TagGroupEntityLeaf) GetMatch() string { + if o == nil || IsNil(o.Match) { + var ret string + return ret + } + return *o.Match +} + +// GetMatchOk returns a tuple with the Match field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *TagGroupEntityLeaf) GetMatchOk() (*string, bool) { + if o == nil || IsNil(o.Match) { + return nil, false + } + return o.Match, true +} + +// HasMatch returns a boolean if a field has been set. +func (o *TagGroupEntityLeaf) HasMatch() bool { + if o != nil && !IsNil(o.Match) { + return true + } + + return false +} + +// SetMatch gets a reference to the given string and assigns it to the Match field. +func (o *TagGroupEntityLeaf) SetMatch(v string) { + o.Match = &v +} + +func (o TagGroupEntityLeaf) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o TagGroupEntityLeaf) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["entities"] = o.Entities + if !IsNil(o.Match) { + toSerialize["match"] = o.Match + } + return toSerialize, nil +} + +func (o *TagGroupEntityLeaf) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "entities", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err; + } + + for _, requiredProperty := range(requiredProperties) { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varTagGroupEntityLeaf := _TagGroupEntityLeaf{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varTagGroupEntityLeaf) + + if err != nil { + return err + } + + *o = TagGroupEntityLeaf(varTagGroupEntityLeaf) + + return err +} + +type NullableTagGroupEntityLeaf struct { + value *TagGroupEntityLeaf + isSet bool +} + +func (v NullableTagGroupEntityLeaf) Get() *TagGroupEntityLeaf { + return v.value +} + +func (v *NullableTagGroupEntityLeaf) Set(val *TagGroupEntityLeaf) { + v.value = val + v.isSet = true +} + +func (v NullableTagGroupEntityLeaf) IsSet() bool { + return v.isSet +} + +func (v *NullableTagGroupEntityLeaf) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableTagGroupEntityLeaf(val *TagGroupEntityLeaf) *NullableTagGroupEntityLeaf { + return &NullableTagGroupEntityLeaf{value: val, isSet: true} +} + +func (v NullableTagGroupEntityLeaf) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableTagGroupEntityLeaf) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/python/.openapi-generator/FILES b/hindsight-clients/python/.openapi-generator/FILES index 51489f6ac3..cd6dab29f1 100644 --- a/hindsight-clients/python/.openapi-generator/FILES +++ b/hindsight-clients/python/.openapi-generator/FILES @@ -154,6 +154,7 @@ hindsight_client_api/models/retry_operation_response.py hindsight_client_api/models/source_facts_include_options.py hindsight_client_api/models/tag_group_and_input.py hindsight_client_api/models/tag_group_and_output.py +hindsight_client_api/models/tag_group_entity_leaf.py hindsight_client_api/models/tag_group_leaf.py hindsight_client_api/models/tag_group_not_input.py hindsight_client_api/models/tag_group_not_output.py diff --git a/hindsight-clients/python/hindsight_client_api/__init__.py b/hindsight-clients/python/hindsight_client_api/__init__.py index 5578db53e2..82da6339e9 100644 --- a/hindsight-clients/python/hindsight_client_api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/__init__.py @@ -178,6 +178,7 @@ from hindsight_client_api.models.source_facts_include_options import SourceFactsIncludeOptions from hindsight_client_api.models.tag_group_and_input import TagGroupAndInput from hindsight_client_api.models.tag_group_and_output import TagGroupAndOutput +from hindsight_client_api.models.tag_group_entity_leaf import TagGroupEntityLeaf from hindsight_client_api.models.tag_group_leaf import TagGroupLeaf from hindsight_client_api.models.tag_group_not_input import TagGroupNotInput from hindsight_client_api.models.tag_group_not_output import TagGroupNotOutput diff --git a/hindsight-clients/python/hindsight_client_api/models/__init__.py b/hindsight-clients/python/hindsight_client_api/models/__init__.py index c372761c48..3444cc2f8b 100644 --- a/hindsight-clients/python/hindsight_client_api/models/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/models/__init__.py @@ -147,6 +147,7 @@ from hindsight_client_api.models.source_facts_include_options import SourceFactsIncludeOptions from hindsight_client_api.models.tag_group_and_input import TagGroupAndInput from hindsight_client_api.models.tag_group_and_output import TagGroupAndOutput +from hindsight_client_api.models.tag_group_entity_leaf import TagGroupEntityLeaf from hindsight_client_api.models.tag_group_leaf import TagGroupLeaf from hindsight_client_api.models.tag_group_not_input import TagGroupNotInput from hindsight_client_api.models.tag_group_not_output import TagGroupNotOutput diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_dry_run_refresh_result.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_dry_run_refresh_result.py index e0e7d0c623..e7cf9ac9ee 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_dry_run_refresh_result.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_dry_run_refresh_result.py @@ -38,13 +38,15 @@ class MentalModelDryRunRefreshResult(BaseModel): effective_mode: StrictStr = Field(description="The mode the refresh actually ran in.") mode_fallback_reason: Optional[StrictStr] = None outcome: StrictStr = Field(description="What a real refresh would do with the document.") + fast_path: Optional[StrictStr] = None + fast_path_fallback_reason: Optional[StrictStr] = None would_persist: StrictBool = Field(description="Whether a real refresh would write new content.") scope: MentalModelRefreshScope = Field(description="The resolved memory scope.") window: MentalModelRefreshWindow = Field(description="The snapshot window read from.") facts: MentalModelFactCounts = Field(description="Facts retrieved versus actually used.") based_on: Optional[Dict[str, List[Dict[str, Any]]]] = Field(default=None, description="The evidence this run would ground the document on, keyed by fact type — the same shape a refresh persists under reflect_response.based_on. Returned so a preview can show its sources without having to write them anywhere.") current_content: StrictStr = Field(description="The model's content as it stands now.") - candidate_content: StrictStr = Field(description="Raw reflect synthesis, before any delta operations.") + candidate_content: StrictStr = Field(description="The document the run's synthesis step produced, before any delta operations: the raw reflect answer when the agentic loop ran. The delta fast path has no synthesis step, so it reports what it would write instead — the current content on tier 0 (nothing new was found), and the post-operation document on tier 1 (identical to preview_content). Compare against preview_content to see what the delta changed.") preview_content: StrictStr = Field(description="The content a real refresh would store: the delta-edited document, or the candidate in full mode.") diff: StrictStr = Field(description="Unified diff from current_content to preview_content. Empty when identical.") delta_operations: Optional[MentalModelDeltaOperations] = None @@ -52,7 +54,7 @@ class MentalModelDryRunRefreshResult(BaseModel): usage: Optional[TokenUsage] = Field(default=None, description="Token usage across the run's LLM calls.") duration_ms: Optional[StrictInt] = Field(default=0, description="Wall-clock duration of the run.") warnings: Optional[List[StrictStr]] = Field(default=None, description="Conditions worth a human's attention, in plain language.") - __properties: ClassVar[List[str]] = ["mental_model_id", "name", "requested_mode", "effective_mode", "mode_fallback_reason", "outcome", "would_persist", "scope", "window", "facts", "based_on", "current_content", "candidate_content", "preview_content", "diff", "delta_operations", "trace", "usage", "duration_ms", "warnings"] + __properties: ClassVar[List[str]] = ["mental_model_id", "name", "requested_mode", "effective_mode", "mode_fallback_reason", "outcome", "fast_path", "fast_path_fallback_reason", "would_persist", "scope", "window", "facts", "based_on", "current_content", "candidate_content", "preview_content", "diff", "delta_operations", "trace", "usage", "duration_ms", "warnings"] @field_validator('requested_mode') def requested_mode_validate_enum(cls, value): @@ -85,6 +87,26 @@ def outcome_validate_enum(cls, value): raise ValueError("must be one of enum values ('content_written', 'content_preserved_no_new_facts', 'refresh_failed_empty_candidate', 'refresh_failed_delta_not_applied')") return value + @field_validator('fast_path') + def fast_path_validate_enum(cls, value): + """Validates the enum""" + if value is None: + return value + + if value not in set(['tier0', 'tier1']): + raise ValueError("must be one of enum values ('tier0', 'tier1')") + return value + + @field_validator('fast_path_fallback_reason') + def fast_path_fallback_reason_validate_enum(cls, value): + """Validates the enum""" + if value is None: + return value + + if value not in set(['no_delta_baseline', 'needs_full_context', 'delta_ops_failed', 'delta_ops_invalid', 'delta_ops_all_skipped']): + raise ValueError("must be one of enum values ('no_delta_baseline', 'needs_full_context', 'delta_ops_failed', 'delta_ops_invalid', 'delta_ops_all_skipped')") + return value + model_config = ConfigDict( populate_by_name=True, validate_assignment=True, @@ -147,6 +169,16 @@ def to_dict(self) -> Dict[str, Any]: if self.mode_fallback_reason is None and "mode_fallback_reason" in self.model_fields_set: _dict['mode_fallback_reason'] = None + # set to None if fast_path (nullable) is None + # and model_fields_set contains the field + if self.fast_path is None and "fast_path" in self.model_fields_set: + _dict['fast_path'] = None + + # set to None if fast_path_fallback_reason (nullable) is None + # and model_fields_set contains the field + if self.fast_path_fallback_reason is None and "fast_path_fallback_reason" in self.model_fields_set: + _dict['fast_path_fallback_reason'] = None + # set to None if delta_operations (nullable) is None # and model_fields_set contains the field if self.delta_operations is None and "delta_operations" in self.model_fields_set: @@ -170,6 +202,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "effective_mode": obj.get("effective_mode"), "mode_fallback_reason": obj.get("mode_fallback_reason"), "outcome": obj.get("outcome"), + "fast_path": obj.get("fast_path"), + "fast_path_fallback_reason": obj.get("fast_path_fallback_reason"), "would_persist": obj.get("would_persist"), "scope": MentalModelRefreshScope.from_dict(obj["scope"]) if obj.get("scope") is not None else None, "window": MentalModelRefreshWindow.from_dict(obj["window"]) if obj.get("window") is not None else None, diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_refresh_scope_tag_groups_inner.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_refresh_scope_tag_groups_inner.py index ef4d77b37c..3cb2dcf212 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_refresh_scope_tag_groups_inner.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_refresh_scope_tag_groups_inner.py @@ -19,12 +19,13 @@ import re # noqa: F401 from pydantic import BaseModel, ConfigDict, Field, StrictStr, ValidationError, field_validator from typing import Optional +from hindsight_client_api.models.tag_group_entity_leaf import TagGroupEntityLeaf from hindsight_client_api.models.tag_group_leaf import TagGroupLeaf from typing import Union, Any, List, Set, TYPE_CHECKING, Optional, Dict from typing_extensions import Literal, Self from pydantic import Field -MENTALMODELREFRESHSCOPETAGGROUPSINNER_ANY_OF_SCHEMAS = ["TagGroupAndOutput", "TagGroupLeaf", "TagGroupNotOutput", "TagGroupOrOutput"] +MENTALMODELREFRESHSCOPETAGGROUPSINNER_ANY_OF_SCHEMAS = ["TagGroupAndOutput", "TagGroupEntityLeaf", "TagGroupLeaf", "TagGroupNotOutput", "TagGroupOrOutput"] class MentalModelRefreshScopeTagGroupsInner(BaseModel): """ @@ -33,17 +34,19 @@ class MentalModelRefreshScopeTagGroupsInner(BaseModel): # data type: TagGroupLeaf anyof_schema_1_validator: Optional[TagGroupLeaf] = None + # data type: TagGroupEntityLeaf + anyof_schema_2_validator: Optional[TagGroupEntityLeaf] = None # data type: TagGroupAndOutput - anyof_schema_2_validator: Optional[TagGroupAndOutput] = None + anyof_schema_3_validator: Optional[TagGroupAndOutput] = None # data type: TagGroupOrOutput - anyof_schema_3_validator: Optional[TagGroupOrOutput] = None + anyof_schema_4_validator: Optional[TagGroupOrOutput] = None # data type: TagGroupNotOutput - anyof_schema_4_validator: Optional[TagGroupNotOutput] = None + anyof_schema_5_validator: Optional[TagGroupNotOutput] = None if TYPE_CHECKING: - actual_instance: Optional[Union[TagGroupAndOutput, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput]] = None + actual_instance: Optional[Union[TagGroupAndOutput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput]] = None else: actual_instance: Any = None - any_of_schemas: Set[str] = { "TagGroupAndOutput", "TagGroupLeaf", "TagGroupNotOutput", "TagGroupOrOutput" } + any_of_schemas: Set[str] = { "TagGroupAndOutput", "TagGroupEntityLeaf", "TagGroupLeaf", "TagGroupNotOutput", "TagGroupOrOutput" } model_config = { "validate_assignment": True, @@ -70,6 +73,12 @@ def actual_instance_must_validate_anyof(cls, v): else: return v + # validate data type: TagGroupEntityLeaf + if not isinstance(v, TagGroupEntityLeaf): + error_messages.append(f"Error! Input type `{type(v)}` is not `TagGroupEntityLeaf`") + else: + return v + # validate data type: TagGroupAndOutput if not isinstance(v, TagGroupAndOutput): error_messages.append(f"Error! Input type `{type(v)}` is not `TagGroupAndOutput`") @@ -90,7 +99,7 @@ def actual_instance_must_validate_anyof(cls, v): if error_messages: # no match - raise ValueError("No match found when setting the actual_instance in MentalModelRefreshScopeTagGroupsInner with anyOf schemas: TagGroupAndOutput, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput. Details: " + ", ".join(error_messages)) + raise ValueError("No match found when setting the actual_instance in MentalModelRefreshScopeTagGroupsInner with anyOf schemas: TagGroupAndOutput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput. Details: " + ", ".join(error_messages)) else: return v @@ -109,19 +118,25 @@ def from_json(cls, json_str: str) -> Self: return instance except (ValidationError, ValueError) as e: error_messages.append(str(e)) - # anyof_schema_2_validator: Optional[TagGroupAndOutput] = None + # anyof_schema_2_validator: Optional[TagGroupEntityLeaf] = None + try: + instance.actual_instance = TagGroupEntityLeaf.from_json(json_str) + return instance + except (ValidationError, ValueError) as e: + error_messages.append(str(e)) + # anyof_schema_3_validator: Optional[TagGroupAndOutput] = None try: instance.actual_instance = TagGroupAndOutput.from_json(json_str) return instance except (ValidationError, ValueError) as e: error_messages.append(str(e)) - # anyof_schema_3_validator: Optional[TagGroupOrOutput] = None + # anyof_schema_4_validator: Optional[TagGroupOrOutput] = None try: instance.actual_instance = TagGroupOrOutput.from_json(json_str) return instance except (ValidationError, ValueError) as e: error_messages.append(str(e)) - # anyof_schema_4_validator: Optional[TagGroupNotOutput] = None + # anyof_schema_5_validator: Optional[TagGroupNotOutput] = None try: instance.actual_instance = TagGroupNotOutput.from_json(json_str) return instance @@ -130,7 +145,7 @@ def from_json(cls, json_str: str) -> Self: if error_messages: # no match - raise ValueError("No match found when deserializing the JSON string into MentalModelRefreshScopeTagGroupsInner with anyOf schemas: TagGroupAndOutput, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput. Details: " + ", ".join(error_messages)) + raise ValueError("No match found when deserializing the JSON string into MentalModelRefreshScopeTagGroupsInner with anyOf schemas: TagGroupAndOutput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput. Details: " + ", ".join(error_messages)) else: return instance @@ -144,7 +159,7 @@ def to_json(self) -> str: else: return json.dumps(self.actual_instance) - def to_dict(self) -> Optional[Union[Dict[str, Any], TagGroupAndOutput, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput]]: + def to_dict(self) -> Optional[Union[Dict[str, Any], TagGroupAndOutput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput]]: """Returns the dict representation of the actual instance""" if self.actual_instance is None: return None diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_refresh_trace.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_refresh_trace.py index 8c406332fa..7a4babede6 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_refresh_trace.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_refresh_trace.py @@ -35,13 +35,15 @@ class MentalModelRefreshTrace(BaseModel): effective_mode: StrictStr = Field(description="Whether the refresh ran as full or delta.") mode_fallback_reason: Optional[StrictStr] = None outcome: StrictStr = Field(description="What the refresh did with the document.") + fast_path: Optional[StrictStr] = None + fast_path_fallback_reason: Optional[StrictStr] = None tool_calls: Optional[List[MentalModelTraceToolCall]] = Field(default=None, description="Reflect tool calls made during the refresh.") llm_calls: Optional[List[LLMCallTrace]] = Field(default=None, description="LLM calls made during the refresh.") delta_operations: Optional[MentalModelDeltaOperations] = None usage: Optional[TokenUsage] = None duration_ms: Optional[StrictInt] = Field(default=0, description="Wall-clock duration of the refresh.") warnings: Optional[List[StrictStr]] = Field(default=None, description="Conditions worth a human's attention, in plain language.") - __properties: ClassVar[List[str]] = ["recorded_at", "effective_mode", "mode_fallback_reason", "outcome", "tool_calls", "llm_calls", "delta_operations", "usage", "duration_ms", "warnings"] + __properties: ClassVar[List[str]] = ["recorded_at", "effective_mode", "mode_fallback_reason", "outcome", "fast_path", "fast_path_fallback_reason", "tool_calls", "llm_calls", "delta_operations", "usage", "duration_ms", "warnings"] @field_validator('effective_mode') def effective_mode_validate_enum(cls, value): @@ -67,6 +69,26 @@ def outcome_validate_enum(cls, value): raise ValueError("must be one of enum values ('content_written', 'content_preserved_no_new_facts', 'refresh_failed_empty_candidate', 'refresh_failed_delta_not_applied')") return value + @field_validator('fast_path') + def fast_path_validate_enum(cls, value): + """Validates the enum""" + if value is None: + return value + + if value not in set(['tier0', 'tier1']): + raise ValueError("must be one of enum values ('tier0', 'tier1')") + return value + + @field_validator('fast_path_fallback_reason') + def fast_path_fallback_reason_validate_enum(cls, value): + """Validates the enum""" + if value is None: + return value + + if value not in set(['no_delta_baseline', 'needs_full_context', 'delta_ops_failed', 'delta_ops_invalid', 'delta_ops_all_skipped']): + raise ValueError("must be one of enum values ('no_delta_baseline', 'needs_full_context', 'delta_ops_failed', 'delta_ops_invalid', 'delta_ops_all_skipped')") + return value + model_config = ConfigDict( populate_by_name=True, validate_assignment=True, @@ -136,6 +158,16 @@ def to_dict(self) -> Dict[str, Any]: if self.mode_fallback_reason is None and "mode_fallback_reason" in self.model_fields_set: _dict['mode_fallback_reason'] = None + # set to None if fast_path (nullable) is None + # and model_fields_set contains the field + if self.fast_path is None and "fast_path" in self.model_fields_set: + _dict['fast_path'] = None + + # set to None if fast_path_fallback_reason (nullable) is None + # and model_fields_set contains the field + if self.fast_path_fallback_reason is None and "fast_path_fallback_reason" in self.model_fields_set: + _dict['fast_path_fallback_reason'] = None + # set to None if delta_operations (nullable) is None # and model_fields_set contains the field if self.delta_operations is None and "delta_operations" in self.model_fields_set: @@ -162,6 +194,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "effective_mode": obj.get("effective_mode"), "mode_fallback_reason": obj.get("mode_fallback_reason"), "outcome": obj.get("outcome"), + "fast_path": obj.get("fast_path"), + "fast_path_fallback_reason": obj.get("fast_path_fallback_reason"), "tool_calls": [MentalModelTraceToolCall.from_dict(_item) for _item in obj["tool_calls"]] if obj.get("tool_calls") is not None else None, "llm_calls": [LLMCallTrace.from_dict(_item) for _item in obj["llm_calls"]] if obj.get("llm_calls") is not None else None, "delta_operations": MentalModelDeltaOperations.from_dict(obj["delta_operations"]) if obj.get("delta_operations") is not None else None, diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input.py index 187bdf2e76..a183d73ea0 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input.py @@ -38,9 +38,10 @@ class MentalModelTriggerInput(BaseModel): include_chunks: Optional[StrictBool] = None recall_max_tokens: Optional[StrictInt] = None recall_chunks_max_tokens: Optional[StrictInt] = None + delta_fast_path: Optional[StrictBool] = None response_schema: Optional[Dict[str, Any]] = None keep_trace: Optional[StrictBool] = Field(default=False, description="If true, every refresh of this mental model records how it reached its result under reflect_response.trace: the mode it ran in and why, the resolved scope and time window, how many facts retrieval returned versus how many the agent used, the tool and LLM calls, and any delta operations. Only the latest refresh's trace is kept. This is the only way to diagnose a cron- or consolidation-driven refresh after the fact, since no human sees those run. Tool outputs are reduced to result counts to keep the stored trace bounded; use LLM request tracing for raw prompts and responses.") - __properties: ClassVar[List[str]] = ["mode", "refresh_after_consolidation", "refresh_cron", "fact_types", "exclude_mental_models", "exclude_mental_model_ids", "tags_match", "tag_groups", "include_chunks", "recall_max_tokens", "recall_chunks_max_tokens", "response_schema", "keep_trace"] + __properties: ClassVar[List[str]] = ["mode", "refresh_after_consolidation", "refresh_cron", "fact_types", "exclude_mental_models", "exclude_mental_model_ids", "tags_match", "tag_groups", "include_chunks", "recall_max_tokens", "recall_chunks_max_tokens", "delta_fast_path", "response_schema", "keep_trace"] @field_validator('mode') def mode_validate_enum(cls, value): @@ -159,6 +160,11 @@ def to_dict(self) -> Dict[str, Any]: if self.recall_chunks_max_tokens is None and "recall_chunks_max_tokens" in self.model_fields_set: _dict['recall_chunks_max_tokens'] = None + # set to None if delta_fast_path (nullable) is None + # and model_fields_set contains the field + if self.delta_fast_path is None and "delta_fast_path" in self.model_fields_set: + _dict['delta_fast_path'] = None + # set to None if response_schema (nullable) is None # and model_fields_set contains the field if self.response_schema is None and "response_schema" in self.model_fields_set: @@ -187,6 +193,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "include_chunks": obj.get("include_chunks"), "recall_max_tokens": obj.get("recall_max_tokens"), "recall_chunks_max_tokens": obj.get("recall_chunks_max_tokens"), + "delta_fast_path": obj.get("delta_fast_path"), "response_schema": obj.get("response_schema"), "keep_trace": obj.get("keep_trace") if obj.get("keep_trace") is not None else False }) diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input_tag_groups_inner.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input_tag_groups_inner.py index 6d2c04fe0b..311a684c29 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input_tag_groups_inner.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input_tag_groups_inner.py @@ -19,12 +19,13 @@ import re # noqa: F401 from pydantic import BaseModel, ConfigDict, Field, StrictStr, ValidationError, field_validator from typing import Optional +from hindsight_client_api.models.tag_group_entity_leaf import TagGroupEntityLeaf from hindsight_client_api.models.tag_group_leaf import TagGroupLeaf from typing import Union, Any, List, Set, TYPE_CHECKING, Optional, Dict from typing_extensions import Literal, Self from pydantic import Field -MENTALMODELTRIGGERINPUTTAGGROUPSINNER_ANY_OF_SCHEMAS = ["TagGroupAndInput", "TagGroupLeaf", "TagGroupNotInput", "TagGroupOrInput"] +MENTALMODELTRIGGERINPUTTAGGROUPSINNER_ANY_OF_SCHEMAS = ["TagGroupAndInput", "TagGroupEntityLeaf", "TagGroupLeaf", "TagGroupNotInput", "TagGroupOrInput"] class MentalModelTriggerInputTagGroupsInner(BaseModel): """ @@ -33,17 +34,19 @@ class MentalModelTriggerInputTagGroupsInner(BaseModel): # data type: TagGroupLeaf anyof_schema_1_validator: Optional[TagGroupLeaf] = None + # data type: TagGroupEntityLeaf + anyof_schema_2_validator: Optional[TagGroupEntityLeaf] = None # data type: TagGroupAndInput - anyof_schema_2_validator: Optional[TagGroupAndInput] = None + anyof_schema_3_validator: Optional[TagGroupAndInput] = None # data type: TagGroupOrInput - anyof_schema_3_validator: Optional[TagGroupOrInput] = None + anyof_schema_4_validator: Optional[TagGroupOrInput] = None # data type: TagGroupNotInput - anyof_schema_4_validator: Optional[TagGroupNotInput] = None + anyof_schema_5_validator: Optional[TagGroupNotInput] = None if TYPE_CHECKING: - actual_instance: Optional[Union[TagGroupAndInput, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput]] = None + actual_instance: Optional[Union[TagGroupAndInput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput]] = None else: actual_instance: Any = None - any_of_schemas: Set[str] = { "TagGroupAndInput", "TagGroupLeaf", "TagGroupNotInput", "TagGroupOrInput" } + any_of_schemas: Set[str] = { "TagGroupAndInput", "TagGroupEntityLeaf", "TagGroupLeaf", "TagGroupNotInput", "TagGroupOrInput" } model_config = { "validate_assignment": True, @@ -70,6 +73,12 @@ def actual_instance_must_validate_anyof(cls, v): else: return v + # validate data type: TagGroupEntityLeaf + if not isinstance(v, TagGroupEntityLeaf): + error_messages.append(f"Error! Input type `{type(v)}` is not `TagGroupEntityLeaf`") + else: + return v + # validate data type: TagGroupAndInput if not isinstance(v, TagGroupAndInput): error_messages.append(f"Error! Input type `{type(v)}` is not `TagGroupAndInput`") @@ -90,7 +99,7 @@ def actual_instance_must_validate_anyof(cls, v): if error_messages: # no match - raise ValueError("No match found when setting the actual_instance in MentalModelTriggerInputTagGroupsInner with anyOf schemas: TagGroupAndInput, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput. Details: " + ", ".join(error_messages)) + raise ValueError("No match found when setting the actual_instance in MentalModelTriggerInputTagGroupsInner with anyOf schemas: TagGroupAndInput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput. Details: " + ", ".join(error_messages)) else: return v @@ -109,19 +118,25 @@ def from_json(cls, json_str: str) -> Self: return instance except (ValidationError, ValueError) as e: error_messages.append(str(e)) - # anyof_schema_2_validator: Optional[TagGroupAndInput] = None + # anyof_schema_2_validator: Optional[TagGroupEntityLeaf] = None + try: + instance.actual_instance = TagGroupEntityLeaf.from_json(json_str) + return instance + except (ValidationError, ValueError) as e: + error_messages.append(str(e)) + # anyof_schema_3_validator: Optional[TagGroupAndInput] = None try: instance.actual_instance = TagGroupAndInput.from_json(json_str) return instance except (ValidationError, ValueError) as e: error_messages.append(str(e)) - # anyof_schema_3_validator: Optional[TagGroupOrInput] = None + # anyof_schema_4_validator: Optional[TagGroupOrInput] = None try: instance.actual_instance = TagGroupOrInput.from_json(json_str) return instance except (ValidationError, ValueError) as e: error_messages.append(str(e)) - # anyof_schema_4_validator: Optional[TagGroupNotInput] = None + # anyof_schema_5_validator: Optional[TagGroupNotInput] = None try: instance.actual_instance = TagGroupNotInput.from_json(json_str) return instance @@ -130,7 +145,7 @@ def from_json(cls, json_str: str) -> Self: if error_messages: # no match - raise ValueError("No match found when deserializing the JSON string into MentalModelTriggerInputTagGroupsInner with anyOf schemas: TagGroupAndInput, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput. Details: " + ", ".join(error_messages)) + raise ValueError("No match found when deserializing the JSON string into MentalModelTriggerInputTagGroupsInner with anyOf schemas: TagGroupAndInput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput. Details: " + ", ".join(error_messages)) else: return instance @@ -144,7 +159,7 @@ def to_json(self) -> str: else: return json.dumps(self.actual_instance) - def to_dict(self) -> Optional[Union[Dict[str, Any], TagGroupAndInput, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput]]: + def to_dict(self) -> Optional[Union[Dict[str, Any], TagGroupAndInput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput]]: """Returns the dict representation of the actual instance""" if self.actual_instance is None: return None diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output.py index 0508ddda65..03ae7cfa35 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output.py @@ -38,9 +38,10 @@ class MentalModelTriggerOutput(BaseModel): include_chunks: Optional[StrictBool] = None recall_max_tokens: Optional[StrictInt] = None recall_chunks_max_tokens: Optional[StrictInt] = None + delta_fast_path: Optional[StrictBool] = None response_schema: Optional[Dict[str, Any]] = None keep_trace: Optional[StrictBool] = Field(default=False, description="If true, every refresh of this mental model records how it reached its result under reflect_response.trace: the mode it ran in and why, the resolved scope and time window, how many facts retrieval returned versus how many the agent used, the tool and LLM calls, and any delta operations. Only the latest refresh's trace is kept. This is the only way to diagnose a cron- or consolidation-driven refresh after the fact, since no human sees those run. Tool outputs are reduced to result counts to keep the stored trace bounded; use LLM request tracing for raw prompts and responses.") - __properties: ClassVar[List[str]] = ["mode", "refresh_after_consolidation", "refresh_cron", "fact_types", "exclude_mental_models", "exclude_mental_model_ids", "tags_match", "tag_groups", "include_chunks", "recall_max_tokens", "recall_chunks_max_tokens", "response_schema", "keep_trace"] + __properties: ClassVar[List[str]] = ["mode", "refresh_after_consolidation", "refresh_cron", "fact_types", "exclude_mental_models", "exclude_mental_model_ids", "tags_match", "tag_groups", "include_chunks", "recall_max_tokens", "recall_chunks_max_tokens", "delta_fast_path", "response_schema", "keep_trace"] @field_validator('mode') def mode_validate_enum(cls, value): @@ -159,6 +160,11 @@ def to_dict(self) -> Dict[str, Any]: if self.recall_chunks_max_tokens is None and "recall_chunks_max_tokens" in self.model_fields_set: _dict['recall_chunks_max_tokens'] = None + # set to None if delta_fast_path (nullable) is None + # and model_fields_set contains the field + if self.delta_fast_path is None and "delta_fast_path" in self.model_fields_set: + _dict['delta_fast_path'] = None + # set to None if response_schema (nullable) is None # and model_fields_set contains the field if self.response_schema is None and "response_schema" in self.model_fields_set: @@ -187,6 +193,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "include_chunks": obj.get("include_chunks"), "recall_max_tokens": obj.get("recall_max_tokens"), "recall_chunks_max_tokens": obj.get("recall_chunks_max_tokens"), + "delta_fast_path": obj.get("delta_fast_path"), "response_schema": obj.get("response_schema"), "keep_trace": obj.get("keep_trace") if obj.get("keep_trace") is not None else False }) diff --git a/hindsight-clients/python/hindsight_client_api/models/model_not.py b/hindsight-clients/python/hindsight_client_api/models/model_not.py index 85b738b9a2..7992f529d2 100644 --- a/hindsight-clients/python/hindsight_client_api/models/model_not.py +++ b/hindsight-clients/python/hindsight_client_api/models/model_not.py @@ -19,12 +19,13 @@ import re # noqa: F401 from pydantic import BaseModel, ConfigDict, Field, StrictStr, ValidationError, field_validator from typing import Optional +from hindsight_client_api.models.tag_group_entity_leaf import TagGroupEntityLeaf from hindsight_client_api.models.tag_group_leaf import TagGroupLeaf from typing import Union, Any, List, Set, TYPE_CHECKING, Optional, Dict from typing_extensions import Literal, Self from pydantic import Field -MODELNOT_ANY_OF_SCHEMAS = ["TagGroupAndInput", "TagGroupLeaf", "TagGroupNotInput", "TagGroupOrInput"] +MODELNOT_ANY_OF_SCHEMAS = ["TagGroupAndInput", "TagGroupEntityLeaf", "TagGroupLeaf", "TagGroupNotInput", "TagGroupOrInput"] class ModelNot(BaseModel): """ @@ -33,17 +34,19 @@ class ModelNot(BaseModel): # data type: TagGroupLeaf anyof_schema_1_validator: Optional[TagGroupLeaf] = None + # data type: TagGroupEntityLeaf + anyof_schema_2_validator: Optional[TagGroupEntityLeaf] = None # data type: TagGroupAndInput - anyof_schema_2_validator: Optional[TagGroupAndInput] = None + anyof_schema_3_validator: Optional[TagGroupAndInput] = None # data type: TagGroupOrInput - anyof_schema_3_validator: Optional[TagGroupOrInput] = None + anyof_schema_4_validator: Optional[TagGroupOrInput] = None # data type: TagGroupNotInput - anyof_schema_4_validator: Optional[TagGroupNotInput] = None + anyof_schema_5_validator: Optional[TagGroupNotInput] = None if TYPE_CHECKING: - actual_instance: Optional[Union[TagGroupAndInput, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput]] = None + actual_instance: Optional[Union[TagGroupAndInput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput]] = None else: actual_instance: Any = None - any_of_schemas: Set[str] = { "TagGroupAndInput", "TagGroupLeaf", "TagGroupNotInput", "TagGroupOrInput" } + any_of_schemas: Set[str] = { "TagGroupAndInput", "TagGroupEntityLeaf", "TagGroupLeaf", "TagGroupNotInput", "TagGroupOrInput" } model_config = { "validate_assignment": True, @@ -70,6 +73,12 @@ def actual_instance_must_validate_anyof(cls, v): else: return v + # validate data type: TagGroupEntityLeaf + if not isinstance(v, TagGroupEntityLeaf): + error_messages.append(f"Error! Input type `{type(v)}` is not `TagGroupEntityLeaf`") + else: + return v + # validate data type: TagGroupAndInput if not isinstance(v, TagGroupAndInput): error_messages.append(f"Error! Input type `{type(v)}` is not `TagGroupAndInput`") @@ -90,7 +99,7 @@ def actual_instance_must_validate_anyof(cls, v): if error_messages: # no match - raise ValueError("No match found when setting the actual_instance in ModelNot with anyOf schemas: TagGroupAndInput, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput. Details: " + ", ".join(error_messages)) + raise ValueError("No match found when setting the actual_instance in ModelNot with anyOf schemas: TagGroupAndInput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput. Details: " + ", ".join(error_messages)) else: return v @@ -109,19 +118,25 @@ def from_json(cls, json_str: str) -> Self: return instance except (ValidationError, ValueError) as e: error_messages.append(str(e)) - # anyof_schema_2_validator: Optional[TagGroupAndInput] = None + # anyof_schema_2_validator: Optional[TagGroupEntityLeaf] = None + try: + instance.actual_instance = TagGroupEntityLeaf.from_json(json_str) + return instance + except (ValidationError, ValueError) as e: + error_messages.append(str(e)) + # anyof_schema_3_validator: Optional[TagGroupAndInput] = None try: instance.actual_instance = TagGroupAndInput.from_json(json_str) return instance except (ValidationError, ValueError) as e: error_messages.append(str(e)) - # anyof_schema_3_validator: Optional[TagGroupOrInput] = None + # anyof_schema_4_validator: Optional[TagGroupOrInput] = None try: instance.actual_instance = TagGroupOrInput.from_json(json_str) return instance except (ValidationError, ValueError) as e: error_messages.append(str(e)) - # anyof_schema_4_validator: Optional[TagGroupNotInput] = None + # anyof_schema_5_validator: Optional[TagGroupNotInput] = None try: instance.actual_instance = TagGroupNotInput.from_json(json_str) return instance @@ -130,7 +145,7 @@ def from_json(cls, json_str: str) -> Self: if error_messages: # no match - raise ValueError("No match found when deserializing the JSON string into ModelNot with anyOf schemas: TagGroupAndInput, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput. Details: " + ", ".join(error_messages)) + raise ValueError("No match found when deserializing the JSON string into ModelNot with anyOf schemas: TagGroupAndInput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput. Details: " + ", ".join(error_messages)) else: return instance @@ -144,7 +159,7 @@ def to_json(self) -> str: else: return json.dumps(self.actual_instance) - def to_dict(self) -> Optional[Union[Dict[str, Any], TagGroupAndInput, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput]]: + def to_dict(self) -> Optional[Union[Dict[str, Any], TagGroupAndInput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotInput, TagGroupOrInput]]: """Returns the dict representation of the actual instance""" if self.actual_instance is None: return None diff --git a/hindsight-clients/python/hindsight_client_api/models/not1.py b/hindsight-clients/python/hindsight_client_api/models/not1.py index 3e093e7336..bf77be9694 100644 --- a/hindsight-clients/python/hindsight_client_api/models/not1.py +++ b/hindsight-clients/python/hindsight_client_api/models/not1.py @@ -19,12 +19,13 @@ import re # noqa: F401 from pydantic import BaseModel, ConfigDict, Field, StrictStr, ValidationError, field_validator from typing import Optional +from hindsight_client_api.models.tag_group_entity_leaf import TagGroupEntityLeaf from hindsight_client_api.models.tag_group_leaf import TagGroupLeaf from typing import Union, Any, List, Set, TYPE_CHECKING, Optional, Dict from typing_extensions import Literal, Self from pydantic import Field -NOT1_ANY_OF_SCHEMAS = ["TagGroupAndOutput", "TagGroupLeaf", "TagGroupNotOutput", "TagGroupOrOutput"] +NOT1_ANY_OF_SCHEMAS = ["TagGroupAndOutput", "TagGroupEntityLeaf", "TagGroupLeaf", "TagGroupNotOutput", "TagGroupOrOutput"] class Not1(BaseModel): """ @@ -33,17 +34,19 @@ class Not1(BaseModel): # data type: TagGroupLeaf anyof_schema_1_validator: Optional[TagGroupLeaf] = None + # data type: TagGroupEntityLeaf + anyof_schema_2_validator: Optional[TagGroupEntityLeaf] = None # data type: TagGroupAndOutput - anyof_schema_2_validator: Optional[TagGroupAndOutput] = None + anyof_schema_3_validator: Optional[TagGroupAndOutput] = None # data type: TagGroupOrOutput - anyof_schema_3_validator: Optional[TagGroupOrOutput] = None + anyof_schema_4_validator: Optional[TagGroupOrOutput] = None # data type: TagGroupNotOutput - anyof_schema_4_validator: Optional[TagGroupNotOutput] = None + anyof_schema_5_validator: Optional[TagGroupNotOutput] = None if TYPE_CHECKING: - actual_instance: Optional[Union[TagGroupAndOutput, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput]] = None + actual_instance: Optional[Union[TagGroupAndOutput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput]] = None else: actual_instance: Any = None - any_of_schemas: Set[str] = { "TagGroupAndOutput", "TagGroupLeaf", "TagGroupNotOutput", "TagGroupOrOutput" } + any_of_schemas: Set[str] = { "TagGroupAndOutput", "TagGroupEntityLeaf", "TagGroupLeaf", "TagGroupNotOutput", "TagGroupOrOutput" } model_config = { "validate_assignment": True, @@ -70,6 +73,12 @@ def actual_instance_must_validate_anyof(cls, v): else: return v + # validate data type: TagGroupEntityLeaf + if not isinstance(v, TagGroupEntityLeaf): + error_messages.append(f"Error! Input type `{type(v)}` is not `TagGroupEntityLeaf`") + else: + return v + # validate data type: TagGroupAndOutput if not isinstance(v, TagGroupAndOutput): error_messages.append(f"Error! Input type `{type(v)}` is not `TagGroupAndOutput`") @@ -90,7 +99,7 @@ def actual_instance_must_validate_anyof(cls, v): if error_messages: # no match - raise ValueError("No match found when setting the actual_instance in Not1 with anyOf schemas: TagGroupAndOutput, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput. Details: " + ", ".join(error_messages)) + raise ValueError("No match found when setting the actual_instance in Not1 with anyOf schemas: TagGroupAndOutput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput. Details: " + ", ".join(error_messages)) else: return v @@ -109,19 +118,25 @@ def from_json(cls, json_str: str) -> Self: return instance except (ValidationError, ValueError) as e: error_messages.append(str(e)) - # anyof_schema_2_validator: Optional[TagGroupAndOutput] = None + # anyof_schema_2_validator: Optional[TagGroupEntityLeaf] = None + try: + instance.actual_instance = TagGroupEntityLeaf.from_json(json_str) + return instance + except (ValidationError, ValueError) as e: + error_messages.append(str(e)) + # anyof_schema_3_validator: Optional[TagGroupAndOutput] = None try: instance.actual_instance = TagGroupAndOutput.from_json(json_str) return instance except (ValidationError, ValueError) as e: error_messages.append(str(e)) - # anyof_schema_3_validator: Optional[TagGroupOrOutput] = None + # anyof_schema_4_validator: Optional[TagGroupOrOutput] = None try: instance.actual_instance = TagGroupOrOutput.from_json(json_str) return instance except (ValidationError, ValueError) as e: error_messages.append(str(e)) - # anyof_schema_4_validator: Optional[TagGroupNotOutput] = None + # anyof_schema_5_validator: Optional[TagGroupNotOutput] = None try: instance.actual_instance = TagGroupNotOutput.from_json(json_str) return instance @@ -130,7 +145,7 @@ def from_json(cls, json_str: str) -> Self: if error_messages: # no match - raise ValueError("No match found when deserializing the JSON string into Not1 with anyOf schemas: TagGroupAndOutput, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput. Details: " + ", ".join(error_messages)) + raise ValueError("No match found when deserializing the JSON string into Not1 with anyOf schemas: TagGroupAndOutput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput. Details: " + ", ".join(error_messages)) else: return instance @@ -144,7 +159,7 @@ def to_json(self) -> str: else: return json.dumps(self.actual_instance) - def to_dict(self) -> Optional[Union[Dict[str, Any], TagGroupAndOutput, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput]]: + def to_dict(self) -> Optional[Union[Dict[str, Any], TagGroupAndOutput, TagGroupEntityLeaf, TagGroupLeaf, TagGroupNotOutput, TagGroupOrOutput]]: """Returns the dict representation of the actual instance""" if self.actual_instance is None: return None diff --git a/hindsight-clients/python/hindsight_client_api/models/tag_group_entity_leaf.py b/hindsight-clients/python/hindsight_client_api/models/tag_group_entity_leaf.py new file mode 100644 index 0000000000..a559b38f16 --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/tag_group_entity_leaf.py @@ -0,0 +1,100 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.9.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator +from typing import Any, ClassVar, Dict, List, Optional +from typing_extensions import Annotated +from typing import Optional, Set +from typing_extensions import Self + +class TagGroupEntityLeaf(BaseModel): + """ + A leaf ENTITY filter: matches memories by the entities they mention. Tags describe which *compartment* a memory lives in; entities describe what it is *about*. On a bank whose tag vocabulary is a handful of broad topics, a tag scope cannot isolate a subject — measured on a production bank (2026-08-08), the best tag scope for one subject reached 25% precision against a 15.5% base rate, while the entity association reached 94.2% precision / 86.2% recall on the same corpus. This leaf makes that association usable anywhere a ``TagGroup`` already is: mental-model refresh scope, the staleness gate, and retrieval filtering, through the same recursive grammar. ``entities`` are canonical names, matched case-insensitively — the same normalisation the entity registry itself enforces via its ``(bank_id, LOWER(canonical_name))`` uniqueness. Association is inheritance-aware: a memory matches if it links the entity directly (``unit_entities``) or through any of its ``source_memory_ids`` — the lane observations use, since consolidation-produced observations carry no direct postings by design (their entity association is transitive through their sources; see ``memories/pg/graph.py:_entity_rows_for_units_sql``). ``match=\"any\"``: mentions at least one listed entity. ``match=\"all\"``: mentions every listed entity (directly or via sources, per entity). Constraints, enforced by ``validate_entity_leaf_placement`` at the API edge: an entity leaf may not appear under ``not`` — the two permissive fallbacks (the Python-side post-filter and the non-memory_units surfaces that strip entity leaves) evaluate an unknown entity constraint as \"matches\", and a NOT over a permissive \"matches\" silently inverts into \"exclude everything\". + """ # noqa: E501 + entities: Annotated[List[StrictStr], Field(min_length=1)] + match: Optional[StrictStr] = 'any' + __properties: ClassVar[List[str]] = ["entities", "match"] + + @field_validator('match') + def match_validate_enum(cls, value): + """Validates the enum""" + if value is None: + return value + + if value not in set(['any', 'all']): + raise ValueError("must be one of enum values ('any', 'all')") + return value + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of TagGroupEntityLeaf from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of TagGroupEntityLeaf from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "entities": obj.get("entities"), + "match": obj.get("match") if obj.get("match") is not None else 'any' + }) + return _obj + + diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index 8edca5312c..3d3edf9b49 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -3120,6 +3120,24 @@ export type MentalModelDryRunRefreshResult = { | "content_preserved_no_new_facts" | "refresh_failed_empty_candidate" | "refresh_failed_delta_not_applied"; + /** + * Fast Path + * + * Which tier of the deterministic delta fast path produced this run, if any. Null means the agentic reflect loop did — either because the fast path was off, did not apply, or handed back (see fast_path_fallback_reason). + */ + fast_path?: "tier0" | "tier1" | null; + /** + * Fast Path Fallback Reason + * + * Why the fast path handed this run back to the agentic loop, if that happened. + */ + fast_path_fallback_reason?: + | "no_delta_baseline" + | "needs_full_context" + | "delta_ops_failed" + | "delta_ops_invalid" + | "delta_ops_all_skipped" + | null; /** * Would Persist * @@ -3157,7 +3175,7 @@ export type MentalModelDryRunRefreshResult = { /** * Candidate Content * - * Raw reflect synthesis, before any delta operations. + * The document the run's synthesis step produced, before any delta operations: the raw reflect answer when the agentic loop ran. The delta fast path has no synthesis step, so it reports what it would write instead — the current content on tier 0 (nothing new was found), and the post-operation document on tier 1 (identical to preview_content). Compare against preview_content to see what the delta changed. */ candidate_content: string; /** @@ -3266,7 +3284,7 @@ export type MentalModelRefreshScope = { * Compound tag expressions used instead of flat tags, when set. */ tag_groups?: Array< - TagGroupLeaf | TagGroupAndOutput | TagGroupOrOutput | TagGroupNotOutput + TagGroupLeaf | TagGroupEntityLeaf | TagGroupAndOutput | TagGroupOrOutput | TagGroupNotOutput > | null; /** * Fact Types @@ -3334,6 +3352,24 @@ export type MentalModelRefreshTrace = { | "content_preserved_no_new_facts" | "refresh_failed_empty_candidate" | "refresh_failed_delta_not_applied"; + /** + * Fast Path + * + * Which tier of the deterministic delta fast path produced this refresh, if any. Null means the agentic reflect loop did. + */ + fast_path?: "tier0" | "tier1" | null; + /** + * Fast Path Fallback Reason + * + * Why the fast path handed this refresh back to the agentic loop, if that happened. + */ + fast_path_fallback_reason?: + | "no_delta_baseline" + | "needs_full_context" + | "delta_ops_failed" + | "delta_ops_invalid" + | "delta_ops_all_skipped" + | null; /** * Tool Calls * @@ -3572,9 +3608,11 @@ export type MentalModelTriggerInput = { /** * Tag Groups * - * Compound boolean tag expressions to use during refresh instead of the model's own tags. When set, these tag groups are passed to reflect and the model's flat tags are NOT used for filtering. Supports nested and/or/not expressions for complex tag-based scoping. + * Compound boolean tag expressions to use during refresh instead of the model's own tags. When set, these tag groups are passed to reflect and the model's flat tags are NOT used for filtering. Supports nested and/or/not expressions for complex tag-based scoping, plus entity leaves ({entities: [names], match: any|all}) that scope by what a memory is ABOUT rather than which tag compartment it lives in -- matched case-insensitively against canonical entity names, including entities reached through an observation's source memories. The same expressions drive the staleness gate, so an entity-scoped model refreshes exactly when facts about its entities arrive. Entity leaves may not appear under 'not'. */ - tag_groups?: Array | null; + tag_groups?: Array< + TagGroupLeaf | TagGroupEntityLeaf | TagGroupAndInput | TagGroupOrInput | TagGroupNotInput + > | null; /** * Include Chunks * @@ -3593,6 +3631,12 @@ export type MentalModelTriggerInput = { * Override the token budget for raw chunks returned by the internal recall during refresh. None means use the bank/global config default (recall_chunks_max_tokens). */ recall_chunks_max_tokens?: number | null; + /** + * Delta Fast Path + * + * Override whether a delta refresh may take the deterministic fast path: fetch the memories created since the last refresh and, if there are any, turn them into edit operations with a single LLM call instead of running the agentic reflect loop. An empty window costs no LLM call at all. The fast path hands back to the loop whenever a surgical edit is not obviously safe, so every outcome is preserved either way. None means use the bank/global config default (mental_model_delta_fast_path). Ignored in full mode, which never takes the fast path. + */ + delta_fast_path?: boolean | null; /** * Response Schema * @@ -3660,10 +3704,10 @@ export type MentalModelTriggerOutput = { /** * Tag Groups * - * Compound boolean tag expressions to use during refresh instead of the model's own tags. When set, these tag groups are passed to reflect and the model's flat tags are NOT used for filtering. Supports nested and/or/not expressions for complex tag-based scoping. + * Compound boolean tag expressions to use during refresh instead of the model's own tags. When set, these tag groups are passed to reflect and the model's flat tags are NOT used for filtering. Supports nested and/or/not expressions for complex tag-based scoping, plus entity leaves ({entities: [names], match: any|all}) that scope by what a memory is ABOUT rather than which tag compartment it lives in -- matched case-insensitively against canonical entity names, including entities reached through an observation's source memories. The same expressions drive the staleness gate, so an entity-scoped model refreshes exactly when facts about its entities arrive. Entity leaves may not appear under 'not'. */ tag_groups?: Array< - TagGroupLeaf | TagGroupAndOutput | TagGroupOrOutput | TagGroupNotOutput + TagGroupLeaf | TagGroupEntityLeaf | TagGroupAndOutput | TagGroupOrOutput | TagGroupNotOutput > | null; /** * Include Chunks @@ -3683,6 +3727,12 @@ export type MentalModelTriggerOutput = { * Override the token budget for raw chunks returned by the internal recall during refresh. None means use the bank/global config default (recall_chunks_max_tokens). */ recall_chunks_max_tokens?: number | null; + /** + * Delta Fast Path + * + * Override whether a delta refresh may take the deterministic fast path: fetch the memories created since the last refresh and, if there are any, turn them into edit operations with a single LLM call instead of running the agentic reflect loop. An empty window costs no LLM call at all. The fast path hands back to the loop whenever a surgical edit is not obviously safe, so every outcome is preserved either way. None means use the bank/global config default (mental_model_delta_fast_path). Ignored in full mode, which never takes the fast path. + */ + delta_fast_path?: boolean | null; /** * Response Schema * @@ -4042,7 +4092,9 @@ export type RecallRequest = { * * Compound tag filter using boolean groups. Groups in the list are AND-ed. Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}. */ - tag_groups?: Array | null; + tag_groups?: Array< + TagGroupLeaf | TagGroupEntityLeaf | TagGroupAndInput | TagGroupOrInput | TagGroupNotInput + > | null; /** * Optional per-stage score floors (all inclusive, AND-ed). `semantic` and `keyword` are retrieval-level cutoffs pushed into the SQL arms (overriding the global similarity/BM25 minimums for this request); `reranker` and `final` are post-ranking filters on the scored results. Any field left unset imposes no floor; omitting `min_scores` entirely (the default) applies no score filtering. Use with care — the reranker's absolute scores are not calibrated across queries (a clearly-relevant match may score ~0.001 even though it is ranked first). */ @@ -4412,7 +4464,9 @@ export type ReflectRequest = { * * Compound tag filter using boolean groups. Groups in the list are AND-ed. Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}. Mutually exclusive with tags. */ - tag_groups?: Array | null; + tag_groups?: Array< + TagGroupLeaf | TagGroupEntityLeaf | TagGroupAndInput | TagGroupOrInput | TagGroupNotInput + > | null; /** * Apply All Directives * @@ -4678,7 +4732,9 @@ export type TagGroupAndInput = { /** * And */ - and: Array; + and: Array< + TagGroupLeaf | TagGroupEntityLeaf | TagGroupAndInput | TagGroupOrInput | TagGroupNotInput + >; }; /** @@ -4690,7 +4746,53 @@ export type TagGroupAndOutput = { /** * And */ - and: Array; + and: Array< + TagGroupLeaf | TagGroupEntityLeaf | TagGroupAndOutput | TagGroupOrOutput | TagGroupNotOutput + >; +}; + +/** + * TagGroupEntityLeaf + * + * A leaf ENTITY filter: matches memories by the entities they mention. + * + * Tags describe which *compartment* a memory lives in; entities describe what it + * is *about*. On a bank whose tag vocabulary is a handful of broad topics, a tag + * scope cannot isolate a subject — measured on a production bank (2026-08-08), + * the best tag scope for one subject reached 25% precision against a 15.5% base + * rate, while the entity association reached 94.2% precision / 86.2% recall on + * the same corpus. This leaf makes that association usable anywhere a + * ``TagGroup`` already is: mental-model refresh scope, the staleness gate, and + * retrieval filtering, through the same recursive grammar. + * + * ``entities`` are canonical names, matched case-insensitively — the same + * normalisation the entity registry itself enforces via its + * ``(bank_id, LOWER(canonical_name))`` uniqueness. + * + * Association is inheritance-aware: a memory matches if it links the entity + * directly (``unit_entities``) or through any of its ``source_memory_ids`` — the + * lane observations use, since consolidation-produced observations carry no + * direct postings by design (their entity association is transitive through + * their sources; see ``memories/pg/graph.py:_entity_rows_for_units_sql``). + * + * ``match="any"``: mentions at least one listed entity. ``match="all"``: mentions + * every listed entity (directly or via sources, per entity). + * + * Constraints, enforced by ``validate_entity_leaf_placement`` at the API edge: + * an entity leaf may not appear under ``not`` — the two permissive fallbacks + * (the Python-side post-filter and the non-memory_units surfaces that strip + * entity leaves) evaluate an unknown entity constraint as "matches", and a NOT + * over a permissive "matches" silently inverts into "exclude everything". + */ +export type TagGroupEntityLeaf = { + /** + * Entities + */ + entities: Array; + /** + * EntityMatch + */ + match?: "any" | "all"; }; /** @@ -4718,7 +4820,7 @@ export type TagGroupNotInput = { /** * Not */ - not: TagGroupLeaf | TagGroupAndInput | TagGroupOrInput | TagGroupNotInput; + not: TagGroupLeaf | TagGroupEntityLeaf | TagGroupAndInput | TagGroupOrInput | TagGroupNotInput; }; /** @@ -4730,7 +4832,7 @@ export type TagGroupNotOutput = { /** * Not */ - not: TagGroupLeaf | TagGroupAndOutput | TagGroupOrOutput | TagGroupNotOutput; + not: TagGroupLeaf | TagGroupEntityLeaf | TagGroupAndOutput | TagGroupOrOutput | TagGroupNotOutput; }; /** @@ -4742,7 +4844,9 @@ export type TagGroupOrInput = { /** * Or */ - or: Array; + or: Array< + TagGroupLeaf | TagGroupEntityLeaf | TagGroupAndInput | TagGroupOrInput | TagGroupNotInput + >; }; /** @@ -4754,7 +4858,9 @@ export type TagGroupOrOutput = { /** * Or */ - or: Array; + or: Array< + TagGroupLeaf | TagGroupEntityLeaf | TagGroupAndOutput | TagGroupOrOutput | TagGroupNotOutput + >; }; /** diff --git a/hindsight-control-plane/src/components/mental-model-diagnostics-view.tsx b/hindsight-control-plane/src/components/mental-model-diagnostics-view.tsx index bd3ad2220c..ba73964f63 100644 --- a/hindsight-control-plane/src/components/mental-model-diagnostics-view.tsx +++ b/hindsight-control-plane/src/components/mental-model-diagnostics-view.tsx @@ -214,6 +214,33 @@ export function TraceSummary({ trace }: { trace: MentalModelRefreshTrace }) { · {fallbackLabels[trace.mode_fallback_reason]} )} + {/* + Which route produced the refresh, and — when the deterministic fast path + declined — why the agentic loop ran anyway. Rendered as the raw API + values in mono, like `effective_mode` directly above, rather than as + translated prose: these are the enum names the dry-run payload, the + stored trace and the docs all use, so a reader comparing this line + against a `reflect_response` sees the same token. + + The enum NAMES sit in `{"..."}` expressions rather than bare JSX text so + `npm run i18n:check` does not read them as untranslated prose: that check + scans JSXText nodes and human-facing attributes, and an expression-container + string literal is neither. Keep them wrapped if you edit this line. + */} + {trace.fast_path && ( + + {" "} + · {"fast_path="} + {trace.fast_path} + + )} + {trace.fast_path_fallback_reason && ( + + {" "} + · {"fast_path_fallback_reason="} + {trace.fast_path_fallback_reason} + + )} ); } diff --git a/hindsight-control-plane/src/lib/api.ts b/hindsight-control-plane/src/lib/api.ts index a7a3245914..9d0ce75066 100644 --- a/hindsight-control-plane/src/lib/api.ts +++ b/hindsight-control-plane/src/lib/api.ts @@ -210,6 +210,7 @@ export interface MentalModel { include_chunks?: boolean; recall_max_tokens?: number; recall_chunks_max_tokens?: number; + delta_fast_path?: boolean; response_schema?: Record; keep_trace?: boolean; }; @@ -235,6 +236,26 @@ export type RefreshOutcome = | "refresh_failed_empty_candidate" | "refresh_failed_delta_not_applied"; +/** + * Which tier of the deterministic delta fast path produced a refresh: `tier0` + * read the window, found nothing new and made no LLM call; `tier1` turned what + * it found into edit operations with exactly one. Null means the agentic + * reflect loop produced it, as every refresh did before the fast path existed. + */ +export type MentalModelFastPathTier = "tier0" | "tier1"; + +/** + * Why the fast path handed a delta refresh back to the agentic loop. Separate + * from `ModeFallbackReason`: the mode is still delta and the outcome is + * whatever the loop then produced — only the route changed. + */ +export type FastPathFallbackReason = + | "no_delta_baseline" + | "needs_full_context" + | "delta_ops_failed" + | "delta_ops_invalid" + | "delta_ops_all_skipped"; + export interface MentalModelRefreshScope { tags?: string[] | null; tags_match: TagsMatch; @@ -270,6 +291,8 @@ export interface MentalModelRefreshTrace { effective_mode: RefreshMode; mode_fallback_reason?: ModeFallbackReason | null; outcome: RefreshOutcome; + fast_path?: MentalModelFastPathTier | null; + fast_path_fallback_reason?: FastPathFallbackReason | null; tool_calls: Array<{ tool: string; reason?: string | null; @@ -296,6 +319,8 @@ export interface MentalModelDryRunRefreshResult { effective_mode: RefreshMode; mode_fallback_reason?: ModeFallbackReason | null; outcome: RefreshOutcome; + fast_path?: MentalModelFastPathTier | null; + fast_path_fallback_reason?: FastPathFallbackReason | null; would_persist: boolean; scope: MentalModelRefreshScope; window: MentalModelRefreshWindow; @@ -1433,6 +1458,7 @@ export class ControlPlaneClient { include_chunks?: boolean; recall_max_tokens?: number; recall_chunks_max_tokens?: number; + delta_fast_path?: boolean; response_schema?: Record; keep_trace?: boolean; }; @@ -1470,6 +1496,7 @@ export class ControlPlaneClient { include_chunks?: boolean; recall_max_tokens?: number; recall_chunks_max_tokens?: number; + delta_fast_path?: boolean; response_schema?: Record; keep_trace?: boolean; }; @@ -1515,6 +1542,7 @@ export class ControlPlaneClient { include_chunks?: boolean; recall_max_tokens?: number; recall_chunks_max_tokens?: number; + delta_fast_path?: boolean; response_schema?: Record; keep_trace?: boolean; }; @@ -1539,6 +1567,7 @@ export class ControlPlaneClient { include_chunks?: boolean; recall_max_tokens?: number; recall_chunks_max_tokens?: number; + delta_fast_path?: boolean; response_schema?: Record; keep_trace?: boolean; }; diff --git a/hindsight-docs/docs/developer/api/mental-models.mdx b/hindsight-docs/docs/developer/api/mental-models.mdx index 0d06471faa..5caae7147e 100644 --- a/hindsight-docs/docs/developer/api/mental-models.mdx +++ b/hindsight-docs/docs/developer/api/mental-models.mdx @@ -129,6 +129,7 @@ Mental models can be configured to **automatically refresh** when observations a | `include_chunks` | bool \| null | null | Override whether the refresh's internal recall returns raw chunk text. `null` uses the bank/global `recall_include_chunks` default. | | `recall_max_tokens` | int \| null | null | Override the token budget for facts retrieved during refresh. `null` uses the bank/global default. | | `recall_chunks_max_tokens` | int \| null | null | Override the token budget for raw chunks retrieved during refresh. `null` uses the bank/global default. | +| `delta_fast_path` | bool \| null | null | Override whether a `delta` refresh may skip the agentic reflect loop: read the memories created since the last refresh and, if there are any, turn them into edit operations with a single LLM call (an empty window costs no call at all). The fast path hands back to the loop whenever a surgical edit is not obviously safe, so every outcome is preserved either way. `null` uses the bank/global `mental_model_delta_fast_path` default. Ignored in `full` mode. See [Refresh Mode](#refresh-mode). | | `response_schema` | object \| null | null | JSON Schema for structured output. When set, each refresh also stores a `structured_output` alongside the markdown content. See [Structured Output](#structured-output) below. | | `keep_trace` | bool | false | Record how each refresh reached its result under `reflect_response.trace`. See [Troubleshoot a Refresh](#troubleshoot-a-refresh). | @@ -200,12 +201,24 @@ Hindsight keeps an authoritative **structured** representation of the document Anything no operation mentions is copied through untouched, so unchanged prose is preserved rather than regenerated and checked. This matters because "preserve the unchanged content" is only a soft constraint on an LLM — generating the next token from a gestalt of the input is what it intrinsically does, so instructed-to-preserve prose drifts over many refreshes. -Failure modes are conservative by design: an operation referencing a section or block that doesn't exist is **dropped** rather than guessed at, and the rest of the operations still apply. The refresh records which ones were dropped and why, so you can see that part of that round's new information didn't make it into the document. +`replace_block`, `remove_block`, and `insert_block` (when its `index` names an existing block) also require an `anchor`: a short verbatim excerpt of the block the model believes is at that index. Each block in the document shown to the model is annotated with its own index, so the model reads the position instead of counting array elements — but a miscount is still possible, and a wrong-but-in-range index is otherwise indistinguishable from a correct one. Before applying the op, Hindsight checks the anchor against the block actually at that index and drops the op on a mismatch (or a missing anchor) instead of risking it landing on the wrong block. + +Failure modes are conservative by design: an operation referencing a section or block that doesn't exist, or whose anchor doesn't match the block at its index, is **dropped** rather than guessed at, and the rest of the operations still apply. The refresh records which ones were dropped and why, so you can see that part of that round's new information didn't make it into the document. Delta mode falls back to a full regeneration automatically in two cases: 1. The mental model has no existing content yet (nothing to anchor edits on). 2. The `source_query` has changed since the last refresh (the topic has shifted; the existing structure may no longer apply). +#### What a delta refresh costs + +Delta mode's premise is incremental work, so a delta refresh reads its window before reaching for the agentic loop: + +- **Nothing new in the window** — the document is preserved and the refresh's watermark advances, with **no LLM call at all**. (`fast_path: "tier0"`, outcome `content_preserved_no_new_facts`.) +- **New memories in the window** — they and the current document go to the operations prompt in **exactly one** call, and the result is applied. (`fast_path: "tier1"`.) +- **Anything less than clearly safe** — no readable baseline, the model reporting that the retrieved facts are not enough to edit correctly, or operations that fail to parse or all bounce — hands the refresh to the agentic loop, which retrieves more broadly and produces the result exactly as it always did. The reason is recorded as `fast_path_fallback_reason` so a loop run is never unexplained. + +Every outcome is reachable either way; what changes is the cost. Set `trigger.delta_fast_path: false` (or `HINDSIGHT_API_MENTAL_MODEL_DELTA_FAST_PATH=false`) to always run the loop. + **A delta refresh never replaces the document with a partial one.** Because delta retrieval only reads memories newer than the last refresh, an answer written from that window covers just the recent slice of the topic — it is material for editing the document, not a replacement for it. So when the edits can't be made at all — the provider call fails, the response can't be read, or every single operation is rejected — the existing content stays exactly as it is and the refresh **fails** instead of completing. Nothing is lost, the refresh's time window is not advanced, and a retry sees the same memories again. The same holds for an empty answer: a populated document is never overwritten with an empty one. | Use Case | Recommended Mode | Why | @@ -381,6 +394,8 @@ The response answers the questions the stored document can't: |-------|-------------------| | `requested_mode` / `effective_mode` | Whether the refresh ran in the mode you configured | | `mode_fallback_reason` | Why the delta edits weren't applied: `no_baseline_content`, `source_query_changed`, `structured_doc_unreadable`, `delta_ops_failed`, or `delta_ops_all_skipped` (every operation was rejected) | +| `fast_path` | Which tier produced the run — `tier0` (window empty, no LLM call), `tier1` (one call), or `null` for the agentic loop | +| `fast_path_fallback_reason` | Why the fast path handed this run to the loop: `no_delta_baseline`, `needs_full_context`, `delta_ops_failed`, `delta_ops_invalid`, or `delta_ops_all_skipped`. `null` with `fast_path: null` means it never ran (full mode, or switched off) | | `scope` | The tags, match mode, and fact types that actually filtered memories — not the ones stored on the model | | `window` | The `created_after`/`created_before` bounds read, and the watermark that would be persisted | | `facts.retrieved` vs `facts.used` | How much retrieval returned versus how much the reflect agent judged relevant | @@ -432,6 +447,8 @@ somewhere else. | `effective_mode` | Whether the run ended up `full` or `delta` | | `mode_fallback_reason` | Why delta was requested but not applied — `no_baseline_content`, `source_query_changed`, `structured_doc_unreadable`, `delta_ops_failed`, `delta_ops_all_skipped` | | `outcome` | `content_written`, `content_preserved_no_new_facts`, `refresh_failed_empty_candidate`, or `refresh_failed_delta_not_applied` (the edits didn't apply, so the document was kept and the refresh failed) | +| `fast_path` | `tier0` (no LLM call), `tier1` (one call), or `null` when the agentic loop produced the run | +| `fast_path_fallback_reason` | Why the fast path handed this run to the loop — `no_delta_baseline`, `needs_full_context`, `delta_ops_failed`, `delta_ops_invalid`, `delta_ops_all_skipped` | | `tool_calls[]` | Per call: `tool`, the agent's `reason`, the full `input`, `result_count`, `duration_ms`, and the `iteration` it belongs to | | `llm_calls[]` | Per call: `scope` (`agent_1`, `agent_2`, …, `final`) and `duration_ms` | | `delta_operations` | The operations emitted in delta mode, `applied` and `skipped` | diff --git a/hindsight-docs/docs/developer/configuration.md b/hindsight-docs/docs/developer/configuration.md index f3299d1977..701f52f395 100644 --- a/hindsight-docs/docs/developer/configuration.md +++ b/hindsight-docs/docs/developer/configuration.md @@ -1772,6 +1772,14 @@ These knobs control the recall tool that runs inside `reflect_async` (e.g. when | `HINDSIGHT_API_RECALL_MAX_TOKENS` | Token budget for facts returned by the internal recall. | `2048` | | `HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS` | Token budget for raw chunks returned by the internal recall. | `1000` | +#### Mental model delta fast path + +Hierarchical — overridable per bank via the [config API](#hierarchical-configuration), and per mental model via the `trigger.delta_fast_path` field (`null` inherits this default). Only applies to `trigger.mode: "delta"`; full-mode refreshes always run the agentic loop. + +| Variable | Description | Default | +|----------|-------------|---------| +| `HINDSIGHT_API_MENTAL_MODEL_DELTA_FAST_PATH` | Whether a delta refresh may skip the agentic reflect loop. When on, the refresh first reads the memories created since the last refresh: an empty window preserves the document with **no LLM call at all**, and a non-empty one turns those memories into edit operations with **exactly one** call, instead of the multi-call loop. The fast path hands back to the loop whenever a surgical edit is not obviously safe (no readable baseline, the model reports the facts are insufficient, or the operations fail to apply), so every outcome remains reachable either way — the difference is what a refresh costs. Set `false` to always run the loop. | `true` | + #### Disposition Disposition traits control how the bank reasons during reflect operations. Each trait is on a scale of 1–5. These are hierarchical — they can be overridden per bank via the [config API](./configuration.md#hierarchical-configuration). diff --git a/hindsight-docs/static/bank-template-schema.json b/hindsight-docs/static/bank-template-schema.json index 38e20e886b..b39f6e977b 100644 --- a/hindsight-docs/static/bank-template-schema.json +++ b/hindsight-docs/static/bank-template-schema.json @@ -919,6 +919,9 @@ { "$ref": "#/$defs/TagGroupLeaf" }, + { + "$ref": "#/$defs/TagGroupEntityLeaf" + }, { "$ref": "#/$defs/TagGroupAnd" }, @@ -937,7 +940,7 @@ } ], "default": null, - "description": "Compound boolean tag expressions to use during refresh instead of the model's own tags. When set, these tag groups are passed to reflect and the model's flat tags are NOT used for filtering. Supports nested and/or/not expressions for complex tag-based scoping.", + "description": "Compound boolean tag expressions to use during refresh instead of the model's own tags. When set, these tag groups are passed to reflect and the model's flat tags are NOT used for filtering. Supports nested and/or/not expressions for complex tag-based scoping, plus entity leaves ({entities: [names], match: any|all}) that scope by what a memory is ABOUT rather than which tag compartment it lives in -- matched case-insensitively against canonical entity names, including entities reached through an observation's source memories. The same expressions drive the staleness gate, so an entity-scoped model refreshes exactly when facts about its entities arrive. Entity leaves may not appear under 'not'.", "title": "Tag Groups" }, "include_chunks": { @@ -979,6 +982,19 @@ "description": "Override the token budget for raw chunks returned by the internal recall during refresh. None means use the bank/global config default (recall_chunks_max_tokens).", "title": "Recall Chunks Max Tokens" }, + "delta_fast_path": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Override whether a delta refresh may take the deterministic fast path: fetch the memories created since the last refresh and, if there are any, turn them into edit operations with a single LLM call instead of running the agentic reflect loop. An empty window costs no LLM call at all. The fast path hands back to the loop whenever a surgical edit is not obviously safe, so every outcome is preserved either way. None means use the bank/global config default (mental_model_delta_fast_path). Ignored in full mode, which never takes the fast path.", + "title": "Delta Fast Path" + }, "response_schema": { "anyOf": [ { @@ -1012,6 +1028,9 @@ { "$ref": "#/$defs/TagGroupLeaf" }, + { + "$ref": "#/$defs/TagGroupEntityLeaf" + }, { "$ref": "#/$defs/TagGroupAnd" }, @@ -1033,6 +1052,33 @@ "title": "TagGroupAnd", "type": "object" }, + "TagGroupEntityLeaf": { + "description": "A leaf ENTITY filter: matches memories by the entities they mention.\n\nTags describe which *compartment* a memory lives in; entities describe what it\nis *about*. On a bank whose tag vocabulary is a handful of broad topics, a tag\nscope cannot isolate a subject \u2014 measured on a production bank (2026-08-08),\nthe best tag scope for one subject reached 25% precision against a 15.5% base\nrate, while the entity association reached 94.2% precision / 86.2% recall on\nthe same corpus. This leaf makes that association usable anywhere a\n``TagGroup`` already is: mental-model refresh scope, the staleness gate, and\nretrieval filtering, through the same recursive grammar.\n\n``entities`` are canonical names, matched case-insensitively \u2014 the same\nnormalisation the entity registry itself enforces via its\n``(bank_id, LOWER(canonical_name))`` uniqueness.\n\nAssociation is inheritance-aware: a memory matches if it links the entity\ndirectly (``unit_entities``) or through any of its ``source_memory_ids`` \u2014 the\nlane observations use, since consolidation-produced observations carry no\ndirect postings by design (their entity association is transitive through\ntheir sources; see ``memories/pg/graph.py:_entity_rows_for_units_sql``).\n\n``match=\"any\"``: mentions at least one listed entity. ``match=\"all\"``: mentions\nevery listed entity (directly or via sources, per entity).\n\nConstraints, enforced by ``validate_entity_leaf_placement`` at the API edge:\nan entity leaf may not appear under ``not`` \u2014 the two permissive fallbacks\n(the Python-side post-filter and the non-memory_units surfaces that strip\nentity leaves) evaluate an unknown entity constraint as \"matches\", and a NOT\nover a permissive \"matches\" silently inverts into \"exclude everything\".", + "properties": { + "entities": { + "items": { + "type": "string" + }, + "minItems": 1, + "title": "Entities", + "type": "array" + }, + "match": { + "default": "any", + "enum": [ + "any", + "all" + ], + "title": "EntityMatch", + "type": "string" + } + }, + "required": [ + "entities" + ], + "title": "TagGroupEntityLeaf", + "type": "object" + }, "TagGroupLeaf": { "description": "A leaf tag filter: matches memories by tag list and match mode.", "properties": { @@ -1070,6 +1116,9 @@ { "$ref": "#/$defs/TagGroupLeaf" }, + { + "$ref": "#/$defs/TagGroupEntityLeaf" + }, { "$ref": "#/$defs/TagGroupAnd" }, @@ -1098,6 +1147,9 @@ { "$ref": "#/$defs/TagGroupLeaf" }, + { + "$ref": "#/$defs/TagGroupEntityLeaf" + }, { "$ref": "#/$defs/TagGroupAnd" }, diff --git a/hindsight-docs/static/openapi.json b/hindsight-docs/static/openapi.json index fcb1dc3651..ad589e5556 100644 --- a/hindsight-docs/static/openapi.json +++ b/hindsight-docs/static/openapi.json @@ -11849,6 +11849,41 @@ "title": "Outcome", "description": "What a real refresh would do with the document." }, + "fast_path": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tier0", + "tier1" + ] + }, + { + "type": "null" + } + ], + "title": "Fast Path", + "description": "Which tier of the deterministic delta fast path produced this run, if any. Null means the agentic reflect loop did \u2014 either because the fast path was off, did not apply, or handed back (see fast_path_fallback_reason)." + }, + "fast_path_fallback_reason": { + "anyOf": [ + { + "type": "string", + "enum": [ + "no_delta_baseline", + "needs_full_context", + "delta_ops_failed", + "delta_ops_invalid", + "delta_ops_all_skipped" + ] + }, + { + "type": "null" + } + ], + "title": "Fast Path Fallback Reason", + "description": "Why the fast path handed this run back to the agentic loop, if that happened." + }, "would_persist": { "type": "boolean", "title": "Would Persist", @@ -11886,7 +11921,7 @@ "candidate_content": { "type": "string", "title": "Candidate Content", - "description": "Raw reflect synthesis, before any delta operations." + "description": "The document the run's synthesis step produced, before any delta operations: the raw reflect answer when the agentic loop ran. The delta fast path has no synthesis step, so it reports what it would write instead \u2014 the current content on tier 0 (nothing new was found), and the post-operation document on tier 1 (identical to preview_content). Compare against preview_content to see what the delta changed." }, "preview_content": { "type": "string", @@ -12047,6 +12082,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Output" }, @@ -12158,6 +12196,41 @@ "title": "Outcome", "description": "What the refresh did with the document." }, + "fast_path": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tier0", + "tier1" + ] + }, + { + "type": "null" + } + ], + "title": "Fast Path", + "description": "Which tier of the deterministic delta fast path produced this refresh, if any. Null means the agentic reflect loop did." + }, + "fast_path_fallback_reason": { + "anyOf": [ + { + "type": "string", + "enum": [ + "no_delta_baseline", + "needs_full_context", + "delta_ops_failed", + "delta_ops_invalid", + "delta_ops_all_skipped" + ] + }, + { + "type": "null" + } + ], + "title": "Fast Path Fallback Reason", + "description": "Why the fast path handed this refresh back to the agentic loop, if that happened." + }, "tool_calls": { "items": { "$ref": "#/components/schemas/MentalModelTraceToolCall" @@ -12565,6 +12638,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Input" }, @@ -12583,7 +12659,7 @@ } ], "title": "Tag Groups", - "description": "Compound boolean tag expressions to use during refresh instead of the model's own tags. When set, these tag groups are passed to reflect and the model's flat tags are NOT used for filtering. Supports nested and/or/not expressions for complex tag-based scoping." + "description": "Compound boolean tag expressions to use during refresh instead of the model's own tags. When set, these tag groups are passed to reflect and the model's flat tags are NOT used for filtering. Supports nested and/or/not expressions for complex tag-based scoping, plus entity leaves ({entities: [names], match: any|all}) that scope by what a memory is ABOUT rather than which tag compartment it lives in -- matched case-insensitively against canonical entity names, including entities reached through an observation's source memories. The same expressions drive the staleness gate, so an entity-scoped model refreshes exactly when facts about its entities arrive. Entity leaves may not appear under 'not'." }, "include_chunks": { "anyOf": [ @@ -12621,6 +12697,18 @@ "title": "Recall Chunks Max Tokens", "description": "Override the token budget for raw chunks returned by the internal recall during refresh. None means use the bank/global config default (recall_chunks_max_tokens)." }, + "delta_fast_path": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Delta Fast Path", + "description": "Override whether a delta refresh may take the deterministic fast path: fetch the memories created since the last refresh and, if there are any, turn them into edit operations with a single LLM call instead of running the agentic reflect loop. An empty window costs no LLM call at all. The fast path hands back to the loop whenever a surgical edit is not obviously safe, so every outcome is preserved either way. None means use the bank/global config default (mental_model_delta_fast_path). Ignored in full mode, which never takes the fast path." + }, "response_schema": { "anyOf": [ { @@ -12743,6 +12831,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Output" }, @@ -12761,7 +12852,7 @@ } ], "title": "Tag Groups", - "description": "Compound boolean tag expressions to use during refresh instead of the model's own tags. When set, these tag groups are passed to reflect and the model's flat tags are NOT used for filtering. Supports nested and/or/not expressions for complex tag-based scoping." + "description": "Compound boolean tag expressions to use during refresh instead of the model's own tags. When set, these tag groups are passed to reflect and the model's flat tags are NOT used for filtering. Supports nested and/or/not expressions for complex tag-based scoping, plus entity leaves ({entities: [names], match: any|all}) that scope by what a memory is ABOUT rather than which tag compartment it lives in -- matched case-insensitively against canonical entity names, including entities reached through an observation's source memories. The same expressions drive the staleness gate, so an entity-scoped model refreshes exactly when facts about its entities arrive. Entity leaves may not appear under 'not'." }, "include_chunks": { "anyOf": [ @@ -12799,6 +12890,18 @@ "title": "Recall Chunks Max Tokens", "description": "Override the token budget for raw chunks returned by the internal recall during refresh. None means use the bank/global config default (recall_chunks_max_tokens)." }, + "delta_fast_path": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Delta Fast Path", + "description": "Override whether a delta refresh may take the deterministic fast path: fetch the memories created since the last refresh and, if there are any, turn them into edit operations with a single LLM call instead of running the agentic reflect loop. An empty window costs no LLM call at all. The fast path hands back to the loop whenever a surgical edit is not obviously safe, so every outcome is preserved either way. None means use the bank/global config default (mental_model_delta_fast_path). Ignored in full mode, which never takes the fast path." + }, "response_schema": { "anyOf": [ { @@ -13436,6 +13539,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Input" }, @@ -14178,6 +14284,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Input" }, @@ -14695,6 +14804,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Input" }, @@ -14725,6 +14837,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Output" }, @@ -14747,6 +14862,33 @@ "title": "TagGroupAnd", "description": "Compound AND group: all child filters must match." }, + "TagGroupEntityLeaf": { + "properties": { + "entities": { + "items": { + "type": "string" + }, + "type": "array", + "minItems": 1, + "title": "Entities" + }, + "match": { + "type": "string", + "enum": [ + "any", + "all" + ], + "title": "EntityMatch", + "default": "any" + } + }, + "type": "object", + "required": [ + "entities" + ], + "title": "TagGroupEntityLeaf", + "description": "A leaf ENTITY filter: matches memories by the entities they mention.\n\nTags describe which *compartment* a memory lives in; entities describe what it\nis *about*. On a bank whose tag vocabulary is a handful of broad topics, a tag\nscope cannot isolate a subject \u2014 measured on a production bank (2026-08-08),\nthe best tag scope for one subject reached 25% precision against a 15.5% base\nrate, while the entity association reached 94.2% precision / 86.2% recall on\nthe same corpus. This leaf makes that association usable anywhere a\n``TagGroup`` already is: mental-model refresh scope, the staleness gate, and\nretrieval filtering, through the same recursive grammar.\n\n``entities`` are canonical names, matched case-insensitively \u2014 the same\nnormalisation the entity registry itself enforces via its\n``(bank_id, LOWER(canonical_name))`` uniqueness.\n\nAssociation is inheritance-aware: a memory matches if it links the entity\ndirectly (``unit_entities``) or through any of its ``source_memory_ids`` \u2014 the\nlane observations use, since consolidation-produced observations carry no\ndirect postings by design (their entity association is transitive through\ntheir sources; see ``memories/pg/graph.py:_entity_rows_for_units_sql``).\n\n``match=\"any\"``: mentions at least one listed entity. ``match=\"all\"``: mentions\nevery listed entity (directly or via sources, per entity).\n\nConstraints, enforced by ``validate_entity_leaf_placement`` at the API edge:\nan entity leaf may not appear under ``not`` \u2014 the two permissive fallbacks\n(the Python-side post-filter and the non-memory_units surfaces that strip\nentity leaves) evaluate an unknown entity constraint as \"matches\", and a NOT\nover a permissive \"matches\" silently inverts into \"exclude everything\"." + }, "TagGroupLeaf": { "properties": { "tags": { @@ -14783,6 +14925,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Input" }, @@ -14810,6 +14955,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Output" }, @@ -14838,6 +14986,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Input" }, @@ -14868,6 +15019,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Output" }, diff --git a/hindsight-embed/hindsight_embed/env.example b/hindsight-embed/hindsight_embed/env.example index d512dc4fc1..7765f4a4d0 100644 --- a/hindsight-embed/hindsight_embed/env.example +++ b/hindsight-embed/hindsight_embed/env.example @@ -282,6 +282,15 @@ HINDSIGHT_API_LOG_LEVEL=info # Cross-encoder rerank of the fused candidates (false = use the RRF order): # HINDSIGHT_API_ENABLE_RERANKING=true +# Mental model refresh: let a delta refresh skip the agentic reflect loop. It +# reads the memories created since the last refresh first — an empty window +# preserves the document with no LLM call, a non-empty one becomes edit +# operations in a single call — and hands back to the loop whenever a surgical +# edit is not obviously safe, so the same outcomes stay reachable either way. +# Hierarchical, and overridable per mental model via trigger.delta_fast_path. +# Set false to always run the loop. +# HINDSIGHT_API_MENTAL_MODEL_DELTA_FAST_PATH=true + # Reranker Configuration (Optional - uses local by default) # Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference) # HINDSIGHT_API_RERANKER_PROVIDER=local diff --git a/skills/hindsight-docs/references/developer/api/mental-models.md b/skills/hindsight-docs/references/developer/api/mental-models.md index 5a5b340c99..9ba8d504ee 100644 --- a/skills/hindsight-docs/references/developer/api/mental-models.md +++ b/skills/hindsight-docs/references/developer/api/mental-models.md @@ -176,6 +176,7 @@ Mental models can be configured to **automatically refresh** when observations a | `include_chunks` | bool \| null | null | Override whether the refresh's internal recall returns raw chunk text. `null` uses the bank/global `recall_include_chunks` default. | | `recall_max_tokens` | int \| null | null | Override the token budget for facts retrieved during refresh. `null` uses the bank/global default. | | `recall_chunks_max_tokens` | int \| null | null | Override the token budget for raw chunks retrieved during refresh. `null` uses the bank/global default. | +| `delta_fast_path` | bool \| null | null | Override whether a `delta` refresh may skip the agentic reflect loop: read the memories created since the last refresh and, if there are any, turn them into edit operations with a single LLM call (an empty window costs no call at all). The fast path hands back to the loop whenever a surgical edit is not obviously safe, so every outcome is preserved either way. `null` uses the bank/global `mental_model_delta_fast_path` default. Ignored in `full` mode. See [Refresh Mode](#refresh-mode). | | `response_schema` | object \| null | null | JSON Schema for structured output. When set, each refresh also stores a `structured_output` alongside the markdown content. See [Structured Output](#structured-output) below. | | `keep_trace` | bool | false | Record how each refresh reached its result under `reflect_response.trace`. See [Troubleshoot a Refresh](#troubleshoot-a-refresh). | @@ -247,12 +248,24 @@ Hindsight keeps an authoritative **structured** representation of the document Anything no operation mentions is copied through untouched, so unchanged prose is preserved rather than regenerated and checked. This matters because "preserve the unchanged content" is only a soft constraint on an LLM — generating the next token from a gestalt of the input is what it intrinsically does, so instructed-to-preserve prose drifts over many refreshes. -Failure modes are conservative by design: an operation referencing a section or block that doesn't exist is **dropped** rather than guessed at, and the rest of the operations still apply. The refresh records which ones were dropped and why, so you can see that part of that round's new information didn't make it into the document. +`replace_block`, `remove_block`, and `insert_block` (when its `index` names an existing block) also require an `anchor`: a short verbatim excerpt of the block the model believes is at that index. Each block in the document shown to the model is annotated with its own index, so the model reads the position instead of counting array elements — but a miscount is still possible, and a wrong-but-in-range index is otherwise indistinguishable from a correct one. Before applying the op, Hindsight checks the anchor against the block actually at that index and drops the op on a mismatch (or a missing anchor) instead of risking it landing on the wrong block. + +Failure modes are conservative by design: an operation referencing a section or block that doesn't exist, or whose anchor doesn't match the block at its index, is **dropped** rather than guessed at, and the rest of the operations still apply. The refresh records which ones were dropped and why, so you can see that part of that round's new information didn't make it into the document. Delta mode falls back to a full regeneration automatically in two cases: 1. The mental model has no existing content yet (nothing to anchor edits on). 2. The `source_query` has changed since the last refresh (the topic has shifted; the existing structure may no longer apply). +#### What a delta refresh costs + +Delta mode's premise is incremental work, so a delta refresh reads its window before reaching for the agentic loop: + +- **Nothing new in the window** — the document is preserved and the refresh's watermark advances, with **no LLM call at all**. (`fast_path: "tier0"`, outcome `content_preserved_no_new_facts`.) +- **New memories in the window** — they and the current document go to the operations prompt in **exactly one** call, and the result is applied. (`fast_path: "tier1"`.) +- **Anything less than clearly safe** — no readable baseline, the model reporting that the retrieved facts are not enough to edit correctly, or operations that fail to parse or all bounce — hands the refresh to the agentic loop, which retrieves more broadly and produces the result exactly as it always did. The reason is recorded as `fast_path_fallback_reason` so a loop run is never unexplained. + +Every outcome is reachable either way; what changes is the cost. Set `trigger.delta_fast_path: false` (or `HINDSIGHT_API_MENTAL_MODEL_DELTA_FAST_PATH=false`) to always run the loop. + **A delta refresh never replaces the document with a partial one.** Because delta retrieval only reads memories newer than the last refresh, an answer written from that window covers just the recent slice of the topic — it is material for editing the document, not a replacement for it. So when the edits can't be made at all — the provider call fails, the response can't be read, or every single operation is rejected — the existing content stays exactly as it is and the refresh **fails** instead of completing. Nothing is lost, the refresh's time window is not advanced, and a retry sees the same memories again. The same holds for an empty answer: a populated document is never overwritten with an empty one. | Use Case | Recommended Mode | Why | @@ -501,6 +514,8 @@ The response answers the questions the stored document can't: |-------|-------------------| | `requested_mode` / `effective_mode` | Whether the refresh ran in the mode you configured | | `mode_fallback_reason` | Why the delta edits weren't applied: `no_baseline_content`, `source_query_changed`, `structured_doc_unreadable`, `delta_ops_failed`, or `delta_ops_all_skipped` (every operation was rejected) | +| `fast_path` | Which tier produced the run — `tier0` (window empty, no LLM call), `tier1` (one call), or `null` for the agentic loop | +| `fast_path_fallback_reason` | Why the fast path handed this run to the loop: `no_delta_baseline`, `needs_full_context`, `delta_ops_failed`, `delta_ops_invalid`, or `delta_ops_all_skipped`. `null` with `fast_path: null` means it never ran (full mode, or switched off) | | `scope` | The tags, match mode, and fact types that actually filtered memories — not the ones stored on the model | | `window` | The `created_after`/`created_before` bounds read, and the watermark that would be persisted | | `facts.retrieved` vs `facts.used` | How much retrieval returned versus how much the reflect agent judged relevant | @@ -551,6 +566,8 @@ somewhere else. | `effective_mode` | Whether the run ended up `full` or `delta` | | `mode_fallback_reason` | Why delta was requested but not applied — `no_baseline_content`, `source_query_changed`, `structured_doc_unreadable`, `delta_ops_failed`, `delta_ops_all_skipped` | | `outcome` | `content_written`, `content_preserved_no_new_facts`, `refresh_failed_empty_candidate`, or `refresh_failed_delta_not_applied` (the edits didn't apply, so the document was kept and the refresh failed) | +| `fast_path` | `tier0` (no LLM call), `tier1` (one call), or `null` when the agentic loop produced the run | +| `fast_path_fallback_reason` | Why the fast path handed this run to the loop — `no_delta_baseline`, `needs_full_context`, `delta_ops_failed`, `delta_ops_invalid`, `delta_ops_all_skipped` | | `tool_calls[]` | Per call: `tool`, the agent's `reason`, the full `input`, `result_count`, `duration_ms`, and the `iteration` it belongs to | | `llm_calls[]` | Per call: `scope` (`agent_1`, `agent_2`, …, `final`) and `duration_ms` | | `delta_operations` | The operations emitted in delta mode, `applied` and `skipped` | diff --git a/skills/hindsight-docs/references/developer/configuration.md b/skills/hindsight-docs/references/developer/configuration.md index 1c8a7bff23..c640f63a8e 100644 --- a/skills/hindsight-docs/references/developer/configuration.md +++ b/skills/hindsight-docs/references/developer/configuration.md @@ -1772,6 +1772,14 @@ These knobs control the recall tool that runs inside `reflect_async` (e.g. when | `HINDSIGHT_API_RECALL_MAX_TOKENS` | Token budget for facts returned by the internal recall. | `2048` | | `HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS` | Token budget for raw chunks returned by the internal recall. | `1000` | +#### Mental model delta fast path + +Hierarchical — overridable per bank via the [config API](#hierarchical-configuration), and per mental model via the `trigger.delta_fast_path` field (`null` inherits this default). Only applies to `trigger.mode: "delta"`; full-mode refreshes always run the agentic loop. + +| Variable | Description | Default | +|----------|-------------|---------| +| `HINDSIGHT_API_MENTAL_MODEL_DELTA_FAST_PATH` | Whether a delta refresh may skip the agentic reflect loop. When on, the refresh first reads the memories created since the last refresh: an empty window preserves the document with **no LLM call at all**, and a non-empty one turns those memories into edit operations with **exactly one** call, instead of the multi-call loop. The fast path hands back to the loop whenever a surgical edit is not obviously safe (no readable baseline, the model reports the facts are insufficient, or the operations fail to apply), so every outcome remains reachable either way — the difference is what a refresh costs. Set `false` to always run the loop. | `true` | + #### Disposition Disposition traits control how the bank reasons during reflect operations. Each trait is on a scale of 1–5. These are hierarchical — they can be overridden per bank via the [config API](./configuration.md#hierarchical-configuration). diff --git a/skills/hindsight-docs/references/openapi.json b/skills/hindsight-docs/references/openapi.json index fcb1dc3651..ad589e5556 100644 --- a/skills/hindsight-docs/references/openapi.json +++ b/skills/hindsight-docs/references/openapi.json @@ -11849,6 +11849,41 @@ "title": "Outcome", "description": "What a real refresh would do with the document." }, + "fast_path": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tier0", + "tier1" + ] + }, + { + "type": "null" + } + ], + "title": "Fast Path", + "description": "Which tier of the deterministic delta fast path produced this run, if any. Null means the agentic reflect loop did \u2014 either because the fast path was off, did not apply, or handed back (see fast_path_fallback_reason)." + }, + "fast_path_fallback_reason": { + "anyOf": [ + { + "type": "string", + "enum": [ + "no_delta_baseline", + "needs_full_context", + "delta_ops_failed", + "delta_ops_invalid", + "delta_ops_all_skipped" + ] + }, + { + "type": "null" + } + ], + "title": "Fast Path Fallback Reason", + "description": "Why the fast path handed this run back to the agentic loop, if that happened." + }, "would_persist": { "type": "boolean", "title": "Would Persist", @@ -11886,7 +11921,7 @@ "candidate_content": { "type": "string", "title": "Candidate Content", - "description": "Raw reflect synthesis, before any delta operations." + "description": "The document the run's synthesis step produced, before any delta operations: the raw reflect answer when the agentic loop ran. The delta fast path has no synthesis step, so it reports what it would write instead \u2014 the current content on tier 0 (nothing new was found), and the post-operation document on tier 1 (identical to preview_content). Compare against preview_content to see what the delta changed." }, "preview_content": { "type": "string", @@ -12047,6 +12082,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Output" }, @@ -12158,6 +12196,41 @@ "title": "Outcome", "description": "What the refresh did with the document." }, + "fast_path": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tier0", + "tier1" + ] + }, + { + "type": "null" + } + ], + "title": "Fast Path", + "description": "Which tier of the deterministic delta fast path produced this refresh, if any. Null means the agentic reflect loop did." + }, + "fast_path_fallback_reason": { + "anyOf": [ + { + "type": "string", + "enum": [ + "no_delta_baseline", + "needs_full_context", + "delta_ops_failed", + "delta_ops_invalid", + "delta_ops_all_skipped" + ] + }, + { + "type": "null" + } + ], + "title": "Fast Path Fallback Reason", + "description": "Why the fast path handed this refresh back to the agentic loop, if that happened." + }, "tool_calls": { "items": { "$ref": "#/components/schemas/MentalModelTraceToolCall" @@ -12565,6 +12638,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Input" }, @@ -12583,7 +12659,7 @@ } ], "title": "Tag Groups", - "description": "Compound boolean tag expressions to use during refresh instead of the model's own tags. When set, these tag groups are passed to reflect and the model's flat tags are NOT used for filtering. Supports nested and/or/not expressions for complex tag-based scoping." + "description": "Compound boolean tag expressions to use during refresh instead of the model's own tags. When set, these tag groups are passed to reflect and the model's flat tags are NOT used for filtering. Supports nested and/or/not expressions for complex tag-based scoping, plus entity leaves ({entities: [names], match: any|all}) that scope by what a memory is ABOUT rather than which tag compartment it lives in -- matched case-insensitively against canonical entity names, including entities reached through an observation's source memories. The same expressions drive the staleness gate, so an entity-scoped model refreshes exactly when facts about its entities arrive. Entity leaves may not appear under 'not'." }, "include_chunks": { "anyOf": [ @@ -12621,6 +12697,18 @@ "title": "Recall Chunks Max Tokens", "description": "Override the token budget for raw chunks returned by the internal recall during refresh. None means use the bank/global config default (recall_chunks_max_tokens)." }, + "delta_fast_path": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Delta Fast Path", + "description": "Override whether a delta refresh may take the deterministic fast path: fetch the memories created since the last refresh and, if there are any, turn them into edit operations with a single LLM call instead of running the agentic reflect loop. An empty window costs no LLM call at all. The fast path hands back to the loop whenever a surgical edit is not obviously safe, so every outcome is preserved either way. None means use the bank/global config default (mental_model_delta_fast_path). Ignored in full mode, which never takes the fast path." + }, "response_schema": { "anyOf": [ { @@ -12743,6 +12831,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Output" }, @@ -12761,7 +12852,7 @@ } ], "title": "Tag Groups", - "description": "Compound boolean tag expressions to use during refresh instead of the model's own tags. When set, these tag groups are passed to reflect and the model's flat tags are NOT used for filtering. Supports nested and/or/not expressions for complex tag-based scoping." + "description": "Compound boolean tag expressions to use during refresh instead of the model's own tags. When set, these tag groups are passed to reflect and the model's flat tags are NOT used for filtering. Supports nested and/or/not expressions for complex tag-based scoping, plus entity leaves ({entities: [names], match: any|all}) that scope by what a memory is ABOUT rather than which tag compartment it lives in -- matched case-insensitively against canonical entity names, including entities reached through an observation's source memories. The same expressions drive the staleness gate, so an entity-scoped model refreshes exactly when facts about its entities arrive. Entity leaves may not appear under 'not'." }, "include_chunks": { "anyOf": [ @@ -12799,6 +12890,18 @@ "title": "Recall Chunks Max Tokens", "description": "Override the token budget for raw chunks returned by the internal recall during refresh. None means use the bank/global config default (recall_chunks_max_tokens)." }, + "delta_fast_path": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Delta Fast Path", + "description": "Override whether a delta refresh may take the deterministic fast path: fetch the memories created since the last refresh and, if there are any, turn them into edit operations with a single LLM call instead of running the agentic reflect loop. An empty window costs no LLM call at all. The fast path hands back to the loop whenever a surgical edit is not obviously safe, so every outcome is preserved either way. None means use the bank/global config default (mental_model_delta_fast_path). Ignored in full mode, which never takes the fast path." + }, "response_schema": { "anyOf": [ { @@ -13436,6 +13539,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Input" }, @@ -14178,6 +14284,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Input" }, @@ -14695,6 +14804,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Input" }, @@ -14725,6 +14837,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Output" }, @@ -14747,6 +14862,33 @@ "title": "TagGroupAnd", "description": "Compound AND group: all child filters must match." }, + "TagGroupEntityLeaf": { + "properties": { + "entities": { + "items": { + "type": "string" + }, + "type": "array", + "minItems": 1, + "title": "Entities" + }, + "match": { + "type": "string", + "enum": [ + "any", + "all" + ], + "title": "EntityMatch", + "default": "any" + } + }, + "type": "object", + "required": [ + "entities" + ], + "title": "TagGroupEntityLeaf", + "description": "A leaf ENTITY filter: matches memories by the entities they mention.\n\nTags describe which *compartment* a memory lives in; entities describe what it\nis *about*. On a bank whose tag vocabulary is a handful of broad topics, a tag\nscope cannot isolate a subject \u2014 measured on a production bank (2026-08-08),\nthe best tag scope for one subject reached 25% precision against a 15.5% base\nrate, while the entity association reached 94.2% precision / 86.2% recall on\nthe same corpus. This leaf makes that association usable anywhere a\n``TagGroup`` already is: mental-model refresh scope, the staleness gate, and\nretrieval filtering, through the same recursive grammar.\n\n``entities`` are canonical names, matched case-insensitively \u2014 the same\nnormalisation the entity registry itself enforces via its\n``(bank_id, LOWER(canonical_name))`` uniqueness.\n\nAssociation is inheritance-aware: a memory matches if it links the entity\ndirectly (``unit_entities``) or through any of its ``source_memory_ids`` \u2014 the\nlane observations use, since consolidation-produced observations carry no\ndirect postings by design (their entity association is transitive through\ntheir sources; see ``memories/pg/graph.py:_entity_rows_for_units_sql``).\n\n``match=\"any\"``: mentions at least one listed entity. ``match=\"all\"``: mentions\nevery listed entity (directly or via sources, per entity).\n\nConstraints, enforced by ``validate_entity_leaf_placement`` at the API edge:\nan entity leaf may not appear under ``not`` \u2014 the two permissive fallbacks\n(the Python-side post-filter and the non-memory_units surfaces that strip\nentity leaves) evaluate an unknown entity constraint as \"matches\", and a NOT\nover a permissive \"matches\" silently inverts into \"exclude everything\"." + }, "TagGroupLeaf": { "properties": { "tags": { @@ -14783,6 +14925,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Input" }, @@ -14810,6 +14955,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Output" }, @@ -14838,6 +14986,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Input" }, @@ -14868,6 +15019,9 @@ { "$ref": "#/components/schemas/TagGroupLeaf" }, + { + "$ref": "#/components/schemas/TagGroupEntityLeaf" + }, { "$ref": "#/components/schemas/TagGroupAnd-Output" },