ltcai 11.5.0 → 11.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/README.md +86 -47
  2. package/docs/CHANGELOG.md +76 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/HYBRID_CLOUD_KG_STREAMING.md +8 -4
  6. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/WORKFLOW_DESIGNER.md +1 -1
  12. package/docs/architecture.md +4 -4
  13. package/docs/kg-schema.md +1 -1
  14. package/docs/v11.5.1_RUST_FULL_LOOP_PLAN.md +85 -0
  15. package/docs/v11.5.2_TIGHT_SHIP_PLAN.md +144 -0
  16. package/lattice_brain/__init__.py +1 -1
  17. package/lattice_brain/graph/proactive.py +1 -1
  18. package/lattice_brain/graph/projection/v2_schema.py +0 -28
  19. package/lattice_brain/graph/vector_index/base.py +0 -3
  20. package/lattice_brain/graph/vector_index/brute_force.py +0 -4
  21. package/lattice_brain/graph/vector_index/hnsw.py +0 -3
  22. package/lattice_brain/graph/vector_index/quantized.py +0 -3
  23. package/lattice_brain/ingestion_jobs.py +0 -3
  24. package/lattice_brain/memory.py +0 -27
  25. package/lattice_brain/portability/sharing.py +0 -4
  26. package/lattice_brain/quality.py +0 -49
  27. package/lattice_brain/runtime/agent_runtime.py +1 -1
  28. package/lattice_brain/runtime/hooks.py +0 -13
  29. package/lattice_brain/runtime/multi_agent.py +1 -1
  30. package/lattice_brain/sealed_box.py +0 -4
  31. package/latticeai/__init__.py +1 -1
  32. package/latticeai/api/admin.py +12 -12
  33. package/latticeai/api/agent_worker_seam.py +389 -0
  34. package/latticeai/api/auth.py +25 -2
  35. package/latticeai/api/chat.py +3 -3
  36. package/latticeai/api/chat_agent_http.py +3 -12
  37. package/latticeai/api/chat_helpers.py +10 -13
  38. package/latticeai/api/computer_use.py +31 -42
  39. package/latticeai/api/health.py +21 -1
  40. package/latticeai/api/local_files.py +14 -0
  41. package/latticeai/api/permissions.py +38 -12
  42. package/latticeai/api/search.py +24 -0
  43. package/latticeai/api/static_routes.py +4 -1
  44. package/latticeai/cli/runtime.py +7 -3
  45. package/latticeai/core/config.py +47 -3
  46. package/latticeai/core/csrf.py +28 -2
  47. package/latticeai/core/embedding_providers/__init__.py +3 -5
  48. package/latticeai/core/embedding_providers/base.py +1 -1
  49. package/latticeai/core/embedding_providers/text.py +1 -1
  50. package/latticeai/core/enterprise.py +1 -4
  51. package/latticeai/core/http_origin.py +146 -0
  52. package/latticeai/core/invitations.py +3 -2
  53. package/latticeai/core/io_utils.py +2 -11
  54. package/latticeai/core/legacy_compatibility.py +1 -1
  55. package/latticeai/core/marketplace.py +1 -1
  56. package/latticeai/core/messages.py +40 -0
  57. package/latticeai/core/model_compat.py +2 -8
  58. package/latticeai/core/module_probe.py +37 -0
  59. package/latticeai/core/run_explain.py +5 -2
  60. package/latticeai/core/run_store.py +2 -2
  61. package/latticeai/core/security.py +13 -0
  62. package/latticeai/core/sessions.py +3 -2
  63. package/latticeai/core/sse.py +25 -0
  64. package/latticeai/core/users.py +0 -9
  65. package/latticeai/core/workspace_graph_trace.py +2 -8
  66. package/latticeai/core/workspace_os.py +0 -10
  67. package/latticeai/core/workspace_os_constants.py +1 -1
  68. package/latticeai/core/workspace_os_utils.py +34 -0
  69. package/latticeai/core/workspace_review_items.py +2 -8
  70. package/latticeai/core/workspace_runs.py +2 -8
  71. package/latticeai/core/workspace_skills.py +2 -7
  72. package/latticeai/models/router/documents.py +0 -35
  73. package/latticeai/models/router/registry.py +0 -4
  74. package/latticeai/runtime/access_runtime.py +16 -4
  75. package/latticeai/runtime/build_phases/features.py +25 -2
  76. package/latticeai/runtime/build_phases/web.py +2 -0
  77. package/latticeai/runtime/feature_toggle_wiring.py +12 -18
  78. package/latticeai/runtime/network_boundary_wiring.py +8 -19
  79. package/latticeai/runtime/permission_mode_wiring.py +6 -13
  80. package/latticeai/runtime/router_registration.py +4 -0
  81. package/latticeai/runtime/runtime_context.py +1 -13
  82. package/latticeai/runtime/service_singletons.py +55 -0
  83. package/latticeai/services/architecture_readiness.py +1 -1
  84. package/latticeai/services/brain_intelligence/proposals.py +0 -5
  85. package/latticeai/services/change_proposals.py +2 -36
  86. package/latticeai/services/chronicle.py +4 -6
  87. package/latticeai/services/command_center.py +4 -4
  88. package/latticeai/services/evidence_actions.py +5 -2
  89. package/latticeai/services/hybrid_chat.py +2 -2
  90. package/latticeai/services/hybrid_policy.py +8 -57
  91. package/latticeai/services/mode_store.py +132 -0
  92. package/latticeai/services/model_capability_registry.py +0 -15
  93. package/latticeai/services/model_catalog.py +0 -7
  94. package/latticeai/services/model_loading.py +3 -2
  95. package/latticeai/services/model_runtime/__init__.py +0 -57
  96. package/latticeai/services/model_runtime/engines.py +27 -128
  97. package/latticeai/services/model_runtime/loading.py +2 -2
  98. package/latticeai/services/network_boundary_service.py +7 -44
  99. package/latticeai/services/permission_mode_service.py +8 -56
  100. package/latticeai/services/product_readiness.py +1 -1
  101. package/latticeai/services/setup_detection.py +67 -1
  102. package/latticeai/services/tool_dispatch.py +0 -4
  103. package/latticeai/services/upload_service.py +7 -20
  104. package/latticeai/services/workspace_service.py +0 -6
  105. package/latticeai/setup/auto_setup.py +15 -26
  106. package/latticeai/setup/wizard/catalog.py +5 -2
  107. package/latticeai/setup/wizard/detect.py +6 -27
  108. package/latticeai/setup/wizard/paths.py +2 -5
  109. package/latticeai/setup/wizard/plans.py +2 -2
  110. package/package.json +2 -4
  111. package/scripts/bump_version.py +14 -1
  112. package/scripts/check_current_release_docs.mjs +1 -1
  113. package/scripts/check_legacy_debt.mjs +1 -1
  114. package/scripts/check_server_i18n.mjs +1 -0
  115. package/scripts/generate_agent_loop_fixtures.py +994 -0
  116. package/scripts/generate_rust_parity_fixtures.py +72 -161
  117. package/scripts/parity_fixture_corpus_context.py +162 -0
  118. package/scripts/parity_fixture_corpus_docgen.py +341 -0
  119. package/scripts/release_screen_claims.json +20 -0
  120. package/src-tauri/Cargo.lock +13 -7
  121. package/src-tauri/Cargo.toml +1 -1
  122. package/src-tauri/src/backend.rs +76 -2
  123. package/src-tauri/src/main.rs +13 -3
  124. package/src-tauri/tauri.conf.json +2 -2
  125. package/static/app/asset-manifest.json +41 -41
  126. package/static/app/assets/{Act-CWnxSCgN.js → Act-DcQizkl1.js} +1 -1
  127. package/static/app/assets/{AdminConsole-BEQYU6kF.js → AdminConsole-cf4npybT.js} +1 -1
  128. package/static/app/assets/{Brain-DWu1BhFg.js → Brain-3VCSHFcn.js} +1 -1
  129. package/static/app/assets/{BrainHome-95Hilr9R.js → BrainHome-Qm8eaztx.js} +1 -1
  130. package/static/app/assets/{BrainSignals-QdeqCpAF.js → BrainSignals-DS9BtKOW.js} +1 -1
  131. package/static/app/assets/{Capture-BHpCxnzb.js → Capture-DiQ219jW.js} +1 -1
  132. package/static/app/assets/{Chronicle-B4xYKoed.js → Chronicle-BGvuAchH.js} +1 -1
  133. package/static/app/assets/{CommandPalette-BVXnttSz.js → CommandPalette-Bqhm0Urn.js} +1 -1
  134. package/static/app/assets/{Library-DgYcHome.js → Library-BV6NnF0a.js} +1 -1
  135. package/static/app/assets/{LivingBrain-CrJLDbf7.js → LivingBrain-GzenJchP.js} +1 -1
  136. package/static/app/assets/{ProductFlow-DFlScKoJ.js → ProductFlow-DEP6-vML.js} +1 -1
  137. package/static/app/assets/{ReviewCard-Cy5f48Pj.js → ReviewCard-CNZ7XjWG.js} +1 -1
  138. package/static/app/assets/{System-NF8IfhTa.js → System-CieofHQa.js} +1 -1
  139. package/static/app/assets/arrow-left-kfsrk0mv.js +1 -0
  140. package/static/app/assets/{bot-CucuhLhm.js → bot-B_K1Tdmw.js} +1 -1
  141. package/static/app/assets/{brain-BBnSryW_.js → brain-DWyaV1L1.js} +1 -1
  142. package/static/app/assets/{button-C2GUj2Ai.js → button-aTn4s84A.js} +1 -1
  143. package/static/app/assets/circle-check-qqLug9nU.js +1 -0
  144. package/static/app/assets/{circle-pause-CbkWzBmG.js → circle-pause-xKgeGXkT.js} +1 -1
  145. package/static/app/assets/{circle-play-7lEaqHdJ.js → circle-play-DkT6tYPX.js} +1 -1
  146. package/static/app/assets/{cpu-DAlCXlIy.js → cpu-85xYObUC.js} +1 -1
  147. package/static/app/assets/{download-RNhuuJwh.js → download-B5Fm7YXo.js} +1 -1
  148. package/static/app/assets/{folder-open-CLW4odzM.js → folder-open-kk2Xa52u.js} +1 -1
  149. package/static/app/assets/{hard-drive-NKEiDIAJ.js → hard-drive-DkA3zBW_.js} +1 -1
  150. package/static/app/assets/{index-DMurvUuR.js → index-BMPdTmlY.js} +3 -3
  151. package/static/app/assets/index-DxmOfNRi.css +2 -0
  152. package/static/app/assets/{input-D2UhPC1X.js → input-B0nRf2jO.js} +1 -1
  153. package/static/app/assets/{link-2-6amKbP_P.js → link-2-Dwb4gnTc.js} +1 -1
  154. package/static/app/assets/{permissionCopy-Cu9TZtdR.js → permissionCopy-CQDUBrOZ.js} +1 -1
  155. package/static/app/assets/{primitives-gPsccucr.js → primitives-SNp0LRJz.js} +1 -1
  156. package/static/app/assets/search-BcHqkjoy.js +1 -0
  157. package/static/app/assets/{share-2-Bau7KkPq.js → share-2-BsrxFglO.js} +1 -1
  158. package/static/app/assets/{shield-alert-BufNYypi.js → shield-alert-5BStfp2_.js} +1 -1
  159. package/static/app/assets/{textarea-BQnVWhYs.js → textarea-Cg8IUA-k.js} +1 -1
  160. package/static/app/assets/{useFocusTrap-B3_w60si.js → useFocusTrap-CYKvE46M.js} +1 -1
  161. package/static/app/assets/{useMutation-BHhCflT6.js → useMutation-CSn9t1op.js} +1 -1
  162. package/static/app/assets/{useQuery-rBWfI-5t.js → useQuery-CY2OI2uy.js} +1 -1
  163. package/static/app/assets/{utils-V_5-wxr5.js → utils-Ddol2RWD.js} +1 -1
  164. package/static/app/assets/{workspace-K1zjYUHj.js → workspace-BqDwOz_p.js} +1 -1
  165. package/static/app/index.html +4 -4
  166. package/static/sw.js +1 -1
  167. package/desktop/electron/README.md +0 -9
  168. package/desktop/electron/main.cjs +0 -58
  169. package/desktop/electron/preload.cjs +0 -5
  170. package/latticeai/core/graph_curator.py +0 -11
  171. package/latticeai/core/hooks.py +0 -11
  172. package/latticeai/core/local_embeddings.py +0 -104
  173. package/latticeai/core/multi_agent.py +0 -11
  174. package/latticeai/core/workflow_engine.py +0 -11
  175. package/latticeai/services/ingestion.py +0 -11
  176. package/latticeai/services/kg_portability.py +0 -11
  177. package/latticeai/services/multimodal_streaming.py +0 -129
  178. package/scripts/measure_brain_home_fill.mjs +0 -141
  179. package/static/app/assets/arrow-left-DwkSYrjR.js +0 -1
  180. package/static/app/assets/circle-check-CxOVPwYq.js +0 -1
  181. package/static/app/assets/index-BLPb5lmE.css +0 -2
  182. package/static/app/assets/search-Cj_TKk_2.js +0 -1
@@ -6,27 +6,23 @@ something keeps proving it is still one. This script is the Python half of that
6
6
  proof: it builds a small, fully deterministic Brain with the **real** write path
7
7
  (``KnowledgeGraphStore._upsert_node`` / ``_upsert_chunk`` / ``_upsert_edge``,
8
8
  the real hash embedder, the real v2 projection and trigram FTS index), then runs
9
- the real ``hybrid_search`` / ``search`` / ``vector_search`` over it and writes
10
- their answers to ``rust/fixtures/golden/``.
11
-
12
- v11.5.0 widens it past search: the same store also carries the conversation
13
- corpus, and the same generator drives the KG relationship/traverse reads, the
14
- service-layer graph and three-channel hybrid, the durable history reads and the
15
- context assembler (:data:`SUITES`).
16
-
17
- Two consumers read what it writes:
18
-
19
- * ``tests/unit/test_rust_parity_contract.py`` re-runs the Python engines against
20
- the committed database and asserts the goldens still hold — so a change to
21
- Python semantics fails loudly instead of silently invalidating the contract
22
- the Rust side is pinned to;
23
- * ``rust/lattice-retrieval/tests/parity.rs`` runs the Rust port against the same
24
- database and the same goldens.
25
-
26
- Determinism is the whole design constraint: every timestamp is written by the
27
- real code and then **backdated**; the two ``datetime.now()`` calls the ports
28
- reach are frozen at :data:`FROZEN_NOW` (recorded in the manifest); LLM concept
29
- extraction is forced off; every environment knob is pinned to its default.
9
+ the real engines over it and writes their answers to ``rust/fixtures/golden/``.
10
+ The suite set widens with the port (:data:`SUITES`): the search engines
11
+ (v11.4.0); the KG relationship/traverse reads, the service layer, the durable
12
+ history reads and the context assembler (v11.5.0); the document-generation
13
+ search, traversal and context builder (v11.5.1, whose corpus and runners live
14
+ in ``scripts/parity_fixture_corpus_docgen.py``).
15
+
16
+ Two consumers read what it writes: ``tests/unit/test_rust_parity_contract.py``
17
+ re-runs the Python entry points against the committed database and asserts the
18
+ goldens still hold — so a change to Python semantics fails loudly instead of
19
+ silently invalidating the contract the Rust side is pinned to — and
20
+ ``rust/lattice-retrieval/tests/{parity,suites}.rs`` run the Rust ports against
21
+ the same database and the same goldens. Determinism is the whole design
22
+ constraint: every timestamp is written by the real code and then **backdated**;
23
+ every ``datetime.now()`` the ports reach is frozen at :data:`FROZEN_NOW`
24
+ (recorded in the manifest); LLM concept extraction is forced off; every
25
+ environment knob is pinned to its default.
30
26
 
31
27
  Usage::
32
28
 
@@ -47,8 +43,15 @@ from pathlib import Path
47
43
  from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
48
44
 
49
45
  REPO_ROOT = Path(__file__).resolve().parents[1]
50
- if str(REPO_ROOT) not in sys.path:
51
- sys.path.insert(0, str(REPO_ROOT))
46
+ # The repo (for the product packages) and this directory: `scripts` is not a
47
+ # package, so the docgen corpus is imported by name off the script directory —
48
+ # the same by-path convention the contract test uses to import this file.
49
+ for _import_root in (REPO_ROOT, Path(__file__).resolve().parent):
50
+ if str(_import_root) not in sys.path:
51
+ sys.path.insert(0, str(_import_root))
52
+
53
+ import parity_fixture_corpus_context as context_corpus # noqa: E402
54
+ import parity_fixture_corpus_docgen as docgen # noqa: E402
52
55
 
53
56
  FIXTURE_DIR = REPO_ROOT / "rust" / "fixtures"
54
57
  GOLDEN_DIR = FIXTURE_DIR / "golden"
@@ -80,11 +83,11 @@ WS_BETA = "ws-beta"
80
83
  # (node_id, type, title, summary, metadata, workspace_id, updated_at)
81
84
  #
82
85
  # Shaped on purpose: every ``type_boost`` type appears and so do types outside it;
83
- # titles/summaries are half Korean (the tokenizer, classifier and extractor all
84
- # branch on script); the ``tie:`` block is five rows sharing one timestamp, one
85
- # type and nothing to match, pinning the (hits, boost, updated_at) → id ASC
86
- # tie-break; two workspaces plus NULL-workspace rows cover all three scoping
87
- # answers (no scoping / empty set / a specific workspace).
86
+ # titles/summaries are half Korean (tokenizer, classifier and extractor all branch
87
+ # on script); the ``tie:`` block is five rows sharing one timestamp, one type and
88
+ # nothing to match, pinning the (hits, boost, updated_at) → id ASC tie-break; two
89
+ # workspaces plus NULL-workspace rows cover all three scoping answers (no scoping
90
+ # / empty set / a specific workspace). The v11.5.1 tail is in the docgen corpus.
88
91
  NODES: List[Tuple[str, str, str, str, Dict[str, Any], Optional[str], str]] = [
89
92
  ("dec:fusion-alpha", "Decision", "Hybrid retrieval fusion stays alpha weighted",
90
93
  "We decided the ranking keeps alpha fusion: lexical rank plus max normalized vector score.",
@@ -186,14 +189,14 @@ NODES: List[Tuple[str, str, str, str, Dict[str, Any], Optional[str], str]] = [
186
189
  ("legacy:global-note", "Document", "Legacy global note",
187
190
  "A legacy row with no workspace, visible only with include_legacy_global.",
188
191
  {}, None, "2026-06-20T10:00:00"),
192
+ *docgen.DOCGEN_NODES,
189
193
  ]
190
194
 
191
195
  # (chunk_id, parent_node_id, index, node_title, text, chunk_fields, workspace, updated_at)
192
196
  #
193
197
  # The shape mirrors ``KnowledgeGraphIngestMixin``: every chunk is BOTH a ``Chunk``
194
- # node (lexical lane + workspace scoping) and a ``chunks`` row with its own
195
- # embedding (vector lane, rolled up to its parent). Getting that duality wrong is
196
- # the whole reason chunk-heavy queries are in the query set.
198
+ # node (lexical lane + workspace scoping) and a ``chunks`` row with its own embedding
199
+ # (vector lane, rolled up to its parent) — the duality chunk-heavy queries pin.
197
200
  CHUNKS: List[Tuple[str, str, int, str, str, Dict[str, Any], Optional[str], str]] = [
198
201
  ("chunk:handbook:1", "doc:handbook", 0, "handbook.pdf chunk 1",
199
202
  "온보딩 체크리스트: 첫째, 폴더를 연결합니다. 둘째, 질문을 합니다. 셋째, 근거를 확인합니다.",
@@ -218,10 +221,10 @@ CHUNKS: List[Tuple[str, str, int, str, str, Dict[str, Any], Optional[str], str]]
218
221
  # orders by ``weight DESC, created_at DESC, id ASC`` and ``traverse`` caps every BFS
219
222
  # round with ``ORDER BY weight DESC, id ASC``, so an edge set sharing one weight and
220
223
  # one clock proves nothing. Three shapes are deliberate — a weight tie broken by
221
- # ``created_at`` (the ``org:lattice`` pair), a weight *and* clock tie broken by edge
222
- # id (``topic:quality``/``deck:review``), and legacy-global endpoints for scoping.
223
- # Types are canonicalized by the write door (``relates_to``/``owns`` → ``MENTIONS``),
224
- # which is why the goldens record uppercase names the fixture never spells.
224
+ # ``created_at`` (``org:lattice``), a weight *and* clock tie broken by edge id
225
+ # (``topic:quality``/``deck:review``), and legacy-global endpoints for scoping. The
226
+ # write door canonicalizes types (``relates_to``/``owns`` → ``MENTIONS``), which is
227
+ # why the goldens record uppercase names the fixture never spells.
225
228
  EDGES: List[Tuple[str, str, str, float, str]] = [
226
229
  ("dec:fusion-alpha", "concept:retrieval", "mentions", 0.9, "2026-07-01T00:00:00"),
227
230
  ("dec:fusion-alpha", "concept:ranking", "mentions", 0.8, "2026-07-02T00:00:00"),
@@ -246,6 +249,7 @@ EDGES: List[Tuple[str, str, str, float, str]] = [
246
249
  ("task:onboarding-checklist", "page:onboarding", "relates_to", 0.15, "2026-05-01T00:00:00"),
247
250
  ("doc:handbook", "doc:retrieval-spec", "relates_to", 0.1, "2026-05-02T00:00:00"),
248
251
  ("tie:a", "tie:b", "relates_to", 1.0, "2026-07-03T00:00:00"),
252
+ *docgen.DOCGEN_EDGES,
249
253
  ]
250
254
 
251
255
  # ── the conversation corpus (episodic memory, same database file) ────────────
@@ -257,7 +261,7 @@ EDGES: List[Tuple[str, str, str, float, str]] = [
257
261
  # ``conversation_id`` (the ``legacy-previous-history`` bucket), a whitespace-only
258
262
  # first message (the ``새 대화`` placeholder and its later upgrade), an
259
263
  # assistant-first conversation (no upgrade), an empty timestamp (the ``or ""``
260
- # fallbacks), extra keys ``metadata_json`` merges flat, and ko/en content.
264
+ # fallbacks), extra keys ``metadata_json`` merges flat, ko/en content.
261
265
  MESSAGES: List[Tuple[Any, ...]] = [
262
266
  ("conv-a", "user", "온보딩 체크리스트 어떻게 시작해?", "jiwon@lattice.ai", "지원", "web", "2026-07-20T09:00:00", WS_ALPHA, "org-1", {}),
263
267
  ("conv-a", "assistant", "먼저 폴더를 연결하세요. 그다음 질문하면 됩니다.", "jiwon@lattice.ai", None, "web", "2026-07-20T09:00:05", WS_ALPHA, "org-1", {}),
@@ -307,39 +311,20 @@ QUERIES: List[Dict[str, Any]] = [
307
311
  {"key": "ws_alpha", "query": "hybrid retrieval ranking", "allowed": [WS_ALPHA]},
308
312
  {"key": "ws_alpha_legacy", "query": "회의 결정 사항", "allowed": [WS_ALPHA], "legacy": True},
309
313
  {"key": "ws_beta", "query": "온보딩 체크리스트", "allowed": [WS_BETA]},
310
- # A vector floor nothing clears — the only way to reach the stale-embedder
311
- # probe and the lexical-only fusion label without breaking the store.
314
+ # A vector floor nothing clears: the stale-embedder probe and the
315
+ # lexical-only fusion label, without breaking the store.
312
316
  {"key": "min_vector_floor", "query": "hybrid retrieval ranking", "min_vector": 0.95},
313
317
  # top_k small enough that the rerank window (top_k * 2) cuts the candidates.
314
318
  {"key": "top_k_small", "query": "hybrid retrieval ranking", "top_k": 3},
315
- # Pinned alpha: no policy, so no class, no rewrite, and no age decay on a
316
- # query that would otherwise be recency-classed.
319
+ # Pinned alpha: no policy, so no class/rewrite/age decay on a recency query.
317
320
  {"key": "alpha_pinned", "query": "지난주 회의 기록", "alpha": 0.2},
318
- # A limit below the FTS hit count, so `ORDER BY rank LIMIT ?` decides which
319
- # rows exist at all — the one place bm25 ordering (and therefore a SQLite
320
- # version difference between the two runtimes) is observable.
321
+ # A limit below the FTS hit count, so `ORDER BY rank LIMIT ?` decides which rows
322
+ # exist at all — the one place bm25 ordering (a SQLite version difference) shows.
321
323
  {"key": "fts_rank_cut", "query": "Tie candidate", "limit": 2, "top_k": 2},
322
324
  ]
323
325
 
324
326
  #: Every branch of the memories section: a workspace hit, one with no kind and an
325
327
  #: empty snippet, a non-workspace row to drop, and one past the limit.
326
- CONTEXT_MEMORIES: Dict[str, Any] = {
327
- "results": [
328
- {"id": "mem-1", "kind": "preference", "snippet": "답변은 한국어로", "score": 0.91, "source": "workspace"},
329
- {"id": "mem-2", "kind": None, "snippet": "", "score": 0.0, "source": "workspace"},
330
- {"id": "mem-3", "kind": "decision", "snippet": "ranking keeps alpha fusion", "score": 0.4, "source": "personal"},
331
- {"id": "mem-4", "kind": "fact", "snippet": "온보딩은 다섯 걸음", "score": 0.3, "source": "workspace"},
332
- ]
333
- }
334
-
335
- #: A ledger with a pathless row, a non-dict row, and more than the ten-row cut.
336
- CONTEXT_ARTIFACTS: List[Any] = [
337
- {"path": "notes/ranking.md", "at": "2026-07-20T09:00:00", "run_id": "run-1"},
338
- {"path": "notes/onboarding.md", "run_id": "run-2"},
339
- {"path": "", "at": "2026-07-20T09:00:01"},
340
- "not-a-dict",
341
- ] + [{"path": f"out/file-{index}.md", "at": None, "run_id": f"r{index}"} for index in range(10)]
342
-
343
328
  #: The Phase-2/3 suites: one spec list per ported entry point.
344
329
  SUITES: Dict[str, List[Dict[str, Any]]] = {
345
330
  "relationship": [
@@ -403,8 +388,8 @@ SUITES: Dict[str, List[Dict[str, Any]]] = {
403
388
  {"key": "en_code", "query": "vector_search() returns"},
404
389
  {"key": "ko_person", "query": "담당자 누구"},
405
390
  {"key": "en_filler", "query": " what is the retrieval specification please "},
406
- # Explicit weights disable BOTH the rewrite and the age decay, on a
407
- # query that would otherwise get both. That asymmetry is the contract.
391
+ # Explicit weights disable BOTH rewrite and age decay on a query that
392
+ # would otherwise get both — that asymmetry is the contract.
408
393
  {"key": "pinned_recency", "query": "지난주 회의 기록", "weights": {"keyword": 0.5, "vector": 0.3, "graph": 0.2}},
409
394
  {"key": "pinned_partial", "query": "hybrid retrieval ranking", "weights": {"graph": 1.0}},
410
395
  {"key": "pinned_zero", "query": "회의 결정 사항", "weights": {"keyword": 0.0, "vector": 0.0, "graph": 0.0}},
@@ -461,21 +446,9 @@ SUITES: Dict[str, List[Dict[str, Any]]] = {
461
446
  {"key": "limit_one", "query": "ranking", "limit": 1},
462
447
  {"key": "scoped_strict", "query": "ranking", "allowed": [WS_BETA], "legacy": False},
463
448
  ],
464
- "context_assemble": [
465
- {"key": "all_seams", "query": "회의 결정 사항", "memories": CONTEXT_MEMORIES, "artifacts": CONTEXT_ARTIFACTS, "notes": "정원 노트: 랭킹은 alpha 융합을 유지한다.", "recent": {"limit": 4}},
466
- {"key": "knowledge_only", "query": "hybrid retrieval ranking"},
467
- {"key": "no_seams", "query": "hybrid retrieval ranking", "knowledge": False},
468
- {"key": "memories_only", "query": "온보딩", "knowledge": False, "memories": CONTEXT_MEMORIES, "memory_limit": 2},
469
- {"key": "artifacts_only", "query": "온보딩", "knowledge": False, "artifacts": CONTEXT_ARTIFACTS},
470
- {"key": "notes_blank", "query": "온보딩", "knowledge": False, "notes": " "},
471
- {"key": "recent_conversation", "query": "온보딩", "knowledge": False, "recent": {"conversation_id": "conv-a", "limit": 10}},
472
- {"key": "recent_personal_workspace", "query": "온보딩", "knowledge": False, "recent": {"workspace_id": "personal", "limit": 6}},
473
- {"key": "recent_user_scoped", "query": "온보딩", "knowledge": False, "recent": {"user_email": "jiwon@lattice.ai", "limit": 5}},
474
- {"key": "budget_tiny", "query": "회의 결정 사항", "memories": CONTEXT_MEMORIES, "artifacts": CONTEXT_ARTIFACTS, "notes": "정원 노트: 랭킹은 alpha 융합을 유지한다.", "recent": {"limit": 4}, "budget": 20},
475
- {"key": "budget_one", "query": "회의 결정 사항", "memories": CONTEXT_MEMORIES, "artifacts": CONTEXT_ARTIFACTS, "notes": "노트", "recent": {"limit": 4}, "budget": 1},
476
- {"key": "budget_zero", "query": "회의 결정 사항", "memories": CONTEXT_MEMORIES, "notes": "노트", "budget": 0},
477
- {"key": "knowledge_limit_one", "query": "hybrid retrieval ranking", "knowledge_limit": 1},
478
- ],
449
+ **context_corpus.CONTEXT_SUITES,
450
+ # v11.5.1: the document-generation ports, specs and runners alike.
451
+ **docgen.DOCGEN_SUITES,
479
452
  }
480
453
 
481
454
  #: Texts whose tokenizer output, hashes and vectors pin the embedding port.
@@ -517,10 +490,12 @@ def pinned_environment() -> Iterator[None]:
517
490
  def frozen_clock() -> Iterator[None]:
518
491
  """Freeze every ported ``datetime.now()`` at :data:`FROZEN_NOW`.
519
492
 
520
- Only the recency-class age decay reads the clock, through the ``datetime``
521
- name its module imported — so rebinding that name is the whole patch. Two
522
- modules do it: the graph-layer and the service-layer ``hybrid_search``.
493
+ Only the recency decay reads the clock, through the ``datetime`` name its
494
+ module imported, so rebinding that name is the whole patch. Three modules
495
+ reach it: the graph-layer and service-layer ``hybrid_search``, and the
496
+ document-generation search's own 14-day half-life.
523
497
  """
498
+ from lattice_brain.graph import retrieval_docgen as docgen_module
524
499
  from lattice_brain.graph.retrieval import hybrid as hybrid_module
525
500
  from latticeai.services import search_service as service_module
526
501
 
@@ -531,7 +506,7 @@ def frozen_clock() -> Iterator[None]:
531
506
  def now(cls, tz=None): # noqa: ARG003 — mirrors datetime.now's signature
532
507
  return frozen
533
508
 
534
- modules = (hybrid_module, service_module)
509
+ modules = (hybrid_module, service_module, docgen_module)
535
510
  originals = [module.datetime for module in modules]
536
511
  for module in modules:
537
512
  module.datetime = _FrozenDatetime
@@ -546,8 +521,8 @@ def frozen_clock() -> Iterator[None]:
546
521
  def rules_only_extraction() -> Iterator[None]:
547
522
  """Force ``_topic_candidates`` down its rule-based path.
548
523
 
549
- The LLM path needs a bound router no fixture run has, but "no router happens
550
- to be bound" is an accident and this contract cannot rest on one.
524
+ The LLM path needs a bound router no fixture run has, but "no router
525
+ happens to be bound" is an accident this contract cannot rest on.
551
526
  """
552
527
  from lattice_brain.graph._kg_common import extraction
553
528
 
@@ -618,13 +593,12 @@ def build_store(db_path: Path) -> None:
618
593
  for from_node, to_node, edge_type, weight, stamp in EDGES:
619
594
  # ``_upsert_edge`` stamps ``created_at`` from the wall clock in BOTH
620
595
  # tables, and the read path is the ``kgv2_edges`` view over ``edges_v2``:
621
- # backdating only the legacy table (as this generator first did) left
622
- # the relationship ordering moving with the clock.
596
+ # backdating only the legacy table left the ordering moving with the clock.
623
597
  edge_id = store._upsert_edge(conn, from_node, to_node, edge_type, weight, {})
624
598
  conn.execute("UPDATE edges SET created_at=? WHERE id=?", (stamp, edge_id))
625
599
  conn.execute("UPDATE edges_v2 SET created_at=? WHERE id=?", (stamp, edge_id))
626
- # ``indexed_at`` decides the candidate scan order (and, when the cap bites,
627
- # which candidates exist at all), so it is assigned rather than inherited.
600
+ # ``indexed_at`` decides the candidate scan order (and, when the cap
601
+ # bites, which candidates exist), so it is assigned rather than inherited.
628
602
  item_ids = [row["item_id"] for row in conn.execute(
629
603
  "SELECT item_id FROM vector_embeddings ORDER BY item_id ASC"
630
604
  ).fetchall()]
@@ -636,8 +610,8 @@ def build_store(db_path: Path) -> None:
636
610
  store.record_embedder_fingerprint()
637
611
  write_conversations(db_path)
638
612
 
639
- # Leave one self-contained file behind: checkpoint the WAL, drop back to a
640
- # rollback journal so no sidecar is committed, and compact.
613
+ # One self-contained file: checkpoint the WAL, drop to a rollback journal
614
+ # so no sidecar is committed, and compact.
641
615
  with sqlite3.connect(str(db_path)) as conn:
642
616
  conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
643
617
  conn.execute("PRAGMA journal_mode=DELETE")
@@ -678,18 +652,18 @@ def run_engine(store, engine: str, spec: Dict[str, Any]) -> Dict[str, Any]:
678
652
  return ENGINES[engine](store, spec)
679
653
 
680
654
 
681
- # ── suites (v11.5.0): KG reads, the service layer, history, context ──────────
655
+ # ── the suites beyond search (v11.5.0/v11.5.1) ───────────────────────────────
682
656
  #
683
657
  # The Phase-1 engines share one query shape (a query set × an engine set); the
684
- # Phase-2/3 ports do not, so each gets its own spec list and runner, both carried
685
- # in the manifest so Rust and Python enumerate exactly the same work.
658
+ # later ports do not, so each gets its own spec list and runner, both carried in
659
+ # the manifest so Rust and Python enumerate exactly the same work.
686
660
 
687
661
 
688
662
  class Harness:
689
663
  """Every Python entry point the v11.5.0 goldens are produced from.
690
664
 
691
665
  ``require_auth=False`` is the loopback-owner configuration the native routes
692
- reproduce: the history scope is whatever the caller passes explicitly.
666
+ reproduce: the history scope is whatever the caller passes.
693
667
  """
694
668
 
695
669
  def __init__(self, db_path: Path):
@@ -718,8 +692,7 @@ def _history_scope(spec: Dict[str, Any]) -> Dict[str, Any]:
718
692
  """The identity/workspace scope every history read takes.
719
693
 
720
694
  ``include_legacy_global`` defaults to ``True`` — ``ConversationStore``'s own
721
- default, the opposite of the graph layer's and the kind of asymmetry a port
722
- gets wrong.
695
+ default, the opposite of the graph layer's and an asymmetry a port gets wrong.
723
696
  """
724
697
  allowed = spec.get("allowed")
725
698
  return {
@@ -796,68 +769,6 @@ def _run_history_search(h: Harness, spec: Dict[str, Any]) -> List[Dict[str, Any]
796
769
  )
797
770
 
798
771
 
799
- def _context_seams(h: Harness, spec: Dict[str, Any]) -> Dict[str, Any]:
800
- """The seam set for one context spec — data seams plus the real engines.
801
-
802
- ``memories`` / ``artifacts`` / ``notes`` are *data* seams: the payload is the
803
- spec, so both runtimes feed the assembler the same bytes and what is under
804
- test is the assembler. ``knowledge`` and ``recent`` are real — the
805
- service-layer hybrid search and the durable history reader.
806
- """
807
- from latticeai.api.chat_helpers import build_recent_chat_context
808
-
809
- # Signatures matter: the assembler inspects them to decide which context
810
- # fields a seam may be handed, so each one declares exactly what it accepts.
811
- seams: Dict[str, Any] = {}
812
- if spec.get("memories") is not None:
813
- memories = spec["memories"]
814
- seams["memory_recall"] = (
815
- lambda query, *, user_email=None, workspace_id=None, limit=5: memories
816
- )
817
- if spec.get("artifacts") is not None:
818
- artifacts = spec["artifacts"]
819
- seams["recent_artifacts"] = (
820
- lambda *, user_email=None, conversation_id=None, workspace_id=None: artifacts
821
- )
822
- if spec.get("knowledge", True):
823
- # Loopback trust: no workspace scoping, exactly as on the native route.
824
- seams["hybrid_search"] = (
825
- lambda query, *, limit=5, user_email=None, workspace_id=None:
826
- h.service.hybrid_search(query, limit=limit)
827
- )
828
- if spec.get("notes") is not None:
829
- notes = spec["notes"]
830
- seams["notes_context"] = lambda query, *, user_email=None, workspace_id=None: notes
831
- if spec.get("recent") is not None:
832
- recent = spec["recent"]
833
- seams["recent_chat"] = (
834
- lambda *, user_email=None, conversation_id=None, workspace_id=None:
835
- build_recent_chat_context(
836
- get_history=h.history_runtime["get_history"],
837
- limit=recent.get("limit", 10),
838
- include_image_missing_replies=recent.get("images", True),
839
- user_email=recent.get("user_email"),
840
- conversation_id=recent.get("conversation_id"),
841
- workspace_id=recent.get("workspace_id"),
842
- )
843
- )
844
- return seams
845
-
846
-
847
- def _run_context_assemble(h: Harness, spec: Dict[str, Any]) -> Dict[str, Any]:
848
- from lattice_brain.context import ContextAssembler
849
-
850
- assembled = ContextAssembler(**_context_seams(h, spec)).assemble(
851
- spec["query"], user_email=spec.get("user_email"),
852
- workspace_id=spec.get("workspace_id"),
853
- conversation_id=spec.get("conversation_id"), budget=spec.get("budget", 2000),
854
- memory_limit=spec.get("memory_limit", 5),
855
- knowledge_limit=spec.get("knowledge_limit", 5),
856
- )
857
- return {"text": assembled.text, "approx_tokens": assembled.approx_tokens,
858
- "trace": assembled.trace()}
859
-
860
-
861
772
  SUITE_RUNNERS: Dict[str, Callable[[Harness, Dict[str, Any]], Any]] = {
862
773
  "relationship": _run_relationship,
863
774
  "traverse": _run_traverse,
@@ -867,7 +778,8 @@ SUITE_RUNNERS: Dict[str, Callable[[Harness, Dict[str, Any]], Any]] = {
867
778
  "conversations": _run_conversations,
868
779
  "conversation_messages": _run_conversation_messages,
869
780
  "history_search": _run_history_search,
870
- "context_assemble": _run_context_assemble,
781
+ **context_corpus.CONTEXT_RUNNERS,
782
+ **docgen.DOCGEN_RUNNERS,
871
783
  }
872
784
 
873
785
 
@@ -901,7 +813,7 @@ def suite_payload(suite: str, spec: Dict[str, Any], result: Any) -> Dict[str, An
901
813
  """One suite golden: the spec that produced it, verbatim, and the answer.
902
814
 
903
815
  The spec rides along rather than being flattened into a fixed ``params``
904
- block: these entry points share no parameter shape.
816
+ block — these entry points share no parameter shape.
905
817
  """
906
818
  return {"suite": suite, "key": spec["key"], "spec": spec, "result": result}
907
819
 
@@ -982,8 +894,7 @@ def main() -> int:
982
894
  _dump(GOLDEN_DIR / "embeddings_golden.json", embeddings_golden())
983
895
  _dump(GOLDEN_DIR / "rounding_golden.json", rounding_golden())
984
896
  _dump(GOLDEN_DIR / "manifest.json", manifest())
985
- # ``KnowledgeGraphStore.__init__`` creates its blob directory eagerly and the
986
- # fixture has no blobs; an empty directory beside an artefact is just noise.
897
+ # The store creates its blob directory eagerly and the fixture has no blobs.
987
898
  blob_dir = STORE_PATH.parent / "blobs"
988
899
  if blob_dir.is_dir() and not any(blob_dir.iterdir()):
989
900
  blob_dir.rmdir()
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env python3
2
+ """The context-assembly half of the Python↔Rust parity corpus (v11.5.2).
3
+
4
+ ``scripts/generate_rust_parity_fixtures.py`` owns the store, the harness and the
5
+ goldens; this module owns the specs and runners for the two context ports, so
6
+ neither file grows past the tree's file-size ceiling. The split follows the
7
+ ``parity_fixture_corpus_docgen`` precedent: specs and their runners live side by
8
+ side, because a spec list that arrives without the runner that answers it is a
9
+ suite that silently checks nothing.
10
+
11
+ Two ports, deliberately separate:
12
+
13
+ * **``context_assemble``** pins :class:`~lattice_brain.context.ContextAssembler`
14
+ over the seams the *production* assembler supplies
15
+ (``latticeai/runtime/context_runtime.py``): memories, artifacts, hybrid
16
+ knowledge and garden notes. It does **not** supply ``recent_chat`` — that seam
17
+ has never been wired live, and a golden that pinned it claimed cross-runtime
18
+ agreement about a code path the product does not execute.
19
+ * **``recent_chat``** pins the recent-conversation transcript as the product
20
+ really builds it: ``chat.py`` prepends ``build_recent_chat_context(...)`` on
21
+ every non-hybrid turn and the agent loop injects the same text each executor
22
+ step. Its Rust twin is ``lattice_retrieval::history::recent_chat``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any, Callable, Dict, List
28
+
29
+ #: ``h`` is the generator's ``Harness``. It is typed ``Any`` rather than
30
+ #: imported: the harness owns the live store and the service layer, so importing
31
+ #: it here would make the corpus depend on the generator that imports *it*.
32
+
33
+ #: The organization workspace the corpus seeds; the same literal the generator
34
+ #: and the docgen corpus use.
35
+ WS_ALPHA = "ws-alpha"
36
+
37
+ CONTEXT_MEMORIES: Dict[str, Any] = {
38
+ "results": [
39
+ {"id": "mem-1", "kind": "preference", "snippet": "답변은 한국어로", "score": 0.91, "source": "workspace"},
40
+ {"id": "mem-2", "kind": None, "snippet": "", "score": 0.0, "source": "workspace"},
41
+ {"id": "mem-3", "kind": "decision", "snippet": "ranking keeps alpha fusion", "score": 0.4, "source": "personal"},
42
+ {"id": "mem-4", "kind": "fact", "snippet": "온보딩은 다섯 걸음", "score": 0.3, "source": "workspace"},
43
+ ]
44
+ }
45
+
46
+ #: A ledger with a pathless row, a non-dict row, and more than the ten-row cut.
47
+ CONTEXT_ARTIFACTS: List[Any] = [
48
+ {"path": "notes/ranking.md", "at": "2026-07-20T09:00:00", "run_id": "run-1"},
49
+ {"path": "notes/onboarding.md", "run_id": "run-2"},
50
+ {"path": "", "at": "2026-07-20T09:00:01"},
51
+ "not-a-dict",
52
+ ] + [{"path": f"out/file-{index}.md", "at": None, "run_id": f"r{index}"} for index in range(10)]
53
+
54
+ def _context_seams(h: Any, spec: Dict[str, Any]) -> Dict[str, Any]:
55
+ """The seam set for one context spec — data seams plus the real engines.
56
+
57
+ ``memories`` / ``artifacts`` / ``notes`` are *data* seams: the payload is the
58
+ spec, so both runtimes feed the assembler the same bytes and what is under
59
+ test is the assembler. ``knowledge`` is a real engine — the service-layer
60
+ hybrid search.
61
+
62
+ There is deliberately no ``recent_chat`` seam here: the production
63
+ assembler does not supply one, so a golden that did would pin agreement
64
+ about a path neither runtime reaches in the product. The recent transcript
65
+ is pinned by the ``recent_chat`` suite instead.
66
+ """
67
+ # Signatures matter: the assembler inspects them to decide which context
68
+ # fields a seam may be handed, so each one declares exactly what it accepts.
69
+ seams: Dict[str, Any] = {}
70
+ if spec.get("memories") is not None:
71
+ memories = spec["memories"]
72
+ seams["memory_recall"] = (
73
+ lambda query, *, user_email=None, workspace_id=None, limit=5: memories
74
+ )
75
+ if spec.get("artifacts") is not None:
76
+ artifacts = spec["artifacts"]
77
+ seams["recent_artifacts"] = (
78
+ lambda *, user_email=None, conversation_id=None, workspace_id=None: artifacts
79
+ )
80
+ if spec.get("knowledge", True):
81
+ # Loopback trust: no workspace scoping, exactly as on the native route.
82
+ seams["hybrid_search"] = (
83
+ lambda query, *, limit=5, user_email=None, workspace_id=None:
84
+ h.service.hybrid_search(query, limit=limit)
85
+ )
86
+ if spec.get("notes") is not None:
87
+ notes = spec["notes"]
88
+ seams["notes_context"] = lambda query, *, user_email=None, workspace_id=None: notes
89
+ return seams
90
+
91
+
92
+ def _run_context_assemble(h: Any, spec: Dict[str, Any]) -> Dict[str, Any]:
93
+ from lattice_brain.context import ContextAssembler
94
+
95
+ assembled = ContextAssembler(**_context_seams(h, spec)).assemble(
96
+ spec["query"], user_email=spec.get("user_email"),
97
+ workspace_id=spec.get("workspace_id"),
98
+ conversation_id=spec.get("conversation_id"), budget=spec.get("budget", 2000),
99
+ memory_limit=spec.get("memory_limit", 5),
100
+ knowledge_limit=spec.get("knowledge_limit", 5),
101
+ )
102
+ return {"text": assembled.text, "approx_tokens": assembled.approx_tokens,
103
+ "trace": assembled.trace()}
104
+
105
+
106
+ def _run_recent_chat(h: Any, spec: Dict[str, Any]) -> str:
107
+ """The recent-conversation transcript, exactly as ``/chat`` builds it."""
108
+ from latticeai.api.chat_helpers import build_recent_chat_context
109
+
110
+ return build_recent_chat_context(
111
+ get_history=h.history_runtime["get_history"],
112
+ limit=spec.get("limit", 10),
113
+ include_image_missing_replies=spec.get("images", True),
114
+ user_email=spec.get("user_email"),
115
+ conversation_id=spec.get("conversation_id"),
116
+ workspace_id=spec.get("workspace_id"),
117
+ )
118
+
119
+
120
+ #: The two context suites, as the generator merges them into :data:`SUITES` /
121
+ #: :data:`SUITE_RUNNERS`.
122
+ CONTEXT_SUITES: Dict[str, List[Dict[str, Any]]] = {
123
+ # 11.5.2: these pin the assembler over the seams the *production* assembler
124
+ # supplies (`latticeai/runtime/context_runtime.py`). The `recent_chat` seam
125
+ # is not one of them — it has never been wired live — so pinning it here
126
+ # claimed cross-runtime agreement about a code path the product does not
127
+ # run. The recent-conversation text itself is still pinned, in the
128
+ # `recent_chat` suite below, against the function chat really calls.
129
+ "context_assemble": [
130
+ {"key": "all_seams", "query": "회의 결정 사항", "memories": CONTEXT_MEMORIES, "artifacts": CONTEXT_ARTIFACTS, "notes": "정원 노트: 랭킹은 alpha 융합을 유지한다."},
131
+ {"key": "knowledge_only", "query": "hybrid retrieval ranking"},
132
+ {"key": "no_seams", "query": "hybrid retrieval ranking", "knowledge": False},
133
+ {"key": "memories_only", "query": "온보딩", "knowledge": False, "memories": CONTEXT_MEMORIES, "memory_limit": 2},
134
+ {"key": "artifacts_only", "query": "온보딩", "knowledge": False, "artifacts": CONTEXT_ARTIFACTS},
135
+ {"key": "notes_blank", "query": "온보딩", "knowledge": False, "notes": " "},
136
+ {"key": "budget_tiny", "query": "회의 결정 사항", "memories": CONTEXT_MEMORIES, "artifacts": CONTEXT_ARTIFACTS, "notes": "정원 노트: 랭킹은 alpha 융합을 유지한다.", "budget": 20},
137
+ {"key": "budget_one", "query": "회의 결정 사항", "memories": CONTEXT_MEMORIES, "artifacts": CONTEXT_ARTIFACTS, "notes": "노트", "budget": 1},
138
+ {"key": "budget_zero", "query": "회의 결정 사항", "memories": CONTEXT_MEMORIES, "notes": "노트", "budget": 0},
139
+ {"key": "knowledge_limit_one", "query": "hybrid retrieval ranking", "knowledge_limit": 1},
140
+ ],
141
+ # The recent-conversation transcript as the product actually builds it:
142
+ # `chat.py` prepends `build_recent_chat_context(...)` on every non-hybrid
143
+ # turn, and the agent loop injects the same text each executor step. This
144
+ # is the live twin of `lattice_retrieval::history::recent_chat`.
145
+ "recent_chat": [
146
+ {"key": "everything"},
147
+ {"key": "conversation", "conversation_id": "conv-a"},
148
+ {"key": "conversation_missing", "conversation_id": "nope"},
149
+ {"key": "personal_workspace", "workspace_id": "personal", "limit": 6},
150
+ {"key": "workspace_alpha", "workspace_id": WS_ALPHA},
151
+ {"key": "user_scoped", "user_email": "jiwon@lattice.ai", "limit": 5},
152
+ {"key": "user_and_conversation", "user_email": "jiwon@lattice.ai", "conversation_id": "conv-a"},
153
+ {"key": "limit_one", "limit": 1},
154
+ {"key": "limit_zero", "limit": 0},
155
+ {"key": "image_replies_dropped", "images": False},
156
+ ],
157
+ }
158
+
159
+ CONTEXT_RUNNERS: Dict[str, Callable[..., Any]] = {
160
+ "context_assemble": _run_context_assemble,
161
+ "recent_chat": _run_recent_chat,
162
+ }