ltcai 9.9.3 → 9.9.5

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 (89) hide show
  1. package/README.md +39 -38
  2. package/docs/CHANGELOG.md +100 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +53 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/discovery_index.py +8 -1
  14. package/lattice_brain/graph/ingest.py +43 -9
  15. package/lattice_brain/graph/projection.py +221 -37
  16. package/lattice_brain/graph/rerank.py +160 -0
  17. package/lattice_brain/graph/retrieval.py +90 -455
  18. package/lattice_brain/graph/retrieval_policy.py +174 -0
  19. package/lattice_brain/graph/retrieval_reads.py +459 -0
  20. package/lattice_brain/graph/retrieval_vector.py +122 -2
  21. package/lattice_brain/graph/store.py +2 -0
  22. package/lattice_brain/portability.py +41 -12
  23. package/lattice_brain/quality.py +26 -6
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/chat.py +6 -0
  27. package/latticeai/api/chat_agent_http.py +262 -52
  28. package/latticeai/api/chat_contracts.py +1 -1
  29. package/latticeai/api/chat_intents.py +37 -18
  30. package/latticeai/api/chat_stream.py +76 -2
  31. package/latticeai/api/knowledge_graph.py +35 -1
  32. package/latticeai/core/agent.py +420 -27
  33. package/latticeai/core/enterprise.py +5 -0
  34. package/latticeai/core/file_generation.py +130 -5
  35. package/latticeai/core/legacy_compatibility.py +1 -1
  36. package/latticeai/core/marketplace.py +1 -1
  37. package/latticeai/core/run_store.py +245 -0
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/integrations/telegram_bot.py +82 -23
  40. package/latticeai/models/router.py +25 -12
  41. package/latticeai/services/architecture_readiness.py +1 -1
  42. package/latticeai/services/command_center.py +90 -1
  43. package/latticeai/services/folder_watch.py +5 -0
  44. package/latticeai/services/funnel_metrics.py +9 -1
  45. package/latticeai/services/product_readiness.py +1 -1
  46. package/latticeai/services/search_service.py +43 -13
  47. package/latticeai/services/tool_dispatch.py +56 -0
  48. package/package.json +2 -1
  49. package/scripts/bench_agent_smoke.py +410 -0
  50. package/scripts/check_current_release_docs.mjs +1 -1
  51. package/scripts/funnel_soft_gate.py +192 -0
  52. package/scripts/run_sidecar_e2e.mjs +159 -0
  53. package/src-tauri/Cargo.lock +1 -1
  54. package/src-tauri/Cargo.toml +1 -1
  55. package/src-tauri/tauri.conf.json +1 -1
  56. package/static/app/asset-manifest.json +29 -28
  57. package/static/app/assets/Act-DmxfbqBp.js +1 -0
  58. package/static/app/assets/{Brain-DEY9jLVt.js → Brain-DK_zKCda.js} +2 -2
  59. package/static/app/assets/BrainHome-CCE_0hni.js +2 -0
  60. package/static/app/assets/BrainSignals-r_J68_zh.js +1 -0
  61. package/static/app/assets/{Capture-CVZ09QXi.js → Capture-CvxwzpJZ.js} +1 -1
  62. package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-CVLiFIQC.js} +1 -1
  63. package/static/app/assets/{Library-Bp0n-HlW.js → Library-4As9VUvy.js} +1 -1
  64. package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-BVWk58MW.js} +1 -1
  65. package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-B-Tz0Um4.js} +1 -1
  66. package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-Va3fPQuj.js} +1 -1
  67. package/static/app/assets/{System-ByQcmJW-.js → System-DSR2zKWd.js} +1 -1
  68. package/static/app/assets/{bot-BNDyZLR7.js → bot-VmP0kkeA.js} +1 -1
  69. package/static/app/assets/circle-pause-T9POo4qy.js +1 -0
  70. package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-Bz5iTD0p.js} +1 -1
  71. package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CJhjRtNq.js} +1 -1
  72. package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-B2K_22VI.js} +1 -1
  73. package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CjJtYqHf.js} +1 -1
  74. package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
  75. package/static/app/assets/{index-CHu7cgj3.js → index-CEu0Wqjl.js} +3 -3
  76. package/static/app/assets/{input-DVDI0YR3.js → input-CSi6OBJ9.js} +1 -1
  77. package/static/app/assets/{navigation-BddhEWA0.js → navigation-BPQqEQwr.js} +1 -1
  78. package/static/app/assets/{network-pYQt5oBu.js → network-Ck1nqc-v.js} +1 -1
  79. package/static/app/assets/{primitives-D7gCdEvS.js → primitives-B2f9N7Dh.js} +1 -1
  80. package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-yA_Y8lOB.js} +1 -1
  81. package/static/app/assets/{textarea-sqQmoBKL.js → textarea-C4wM0D-Z.js} +1 -1
  82. package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-CsnIhsDJ.js} +1 -1
  83. package/static/app/assets/utils-BqoznzGP.js +7 -0
  84. package/static/app/index.html +4 -4
  85. package/static/sw.js +1 -1
  86. package/static/app/assets/Act-DmdruVKV.js +0 -1
  87. package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
  88. package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
  89. package/static/app/assets/utils-uQYKXNeq.js +0 -7
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env python3
2
+ """Weekly real-model agent-loop smoke (review Wave 3.4) — FAIL-OPEN.
3
+
4
+ What it measures
5
+ ================
6
+ ``scripts/agent_eval.py`` proves the loop's state machine against *scripted*
7
+ model replies. This harness closes the remaining gap: it drives a small set of
8
+ canonical agent tasks through the REAL :class:`latticeai.core.agent.
9
+ SingleAgentRuntime` (plan → approve → execute → verify), with the generation
10
+ port wired to each *locally installed* MLX model — real inference, no HTTP
11
+ server. Per model and task it reports:
12
+
13
+ * ``final_state`` — the loop's honest terminal state (DONE / NEEDS_REVIEW /
14
+ FAILED), plus the ``agent_eval`` result-class bucket;
15
+ * ``steps`` — LLM calls the run needed;
16
+ * parse repairs — ``parse_errors`` / ``parse_recovered`` and the LoopTrace
17
+ ``repairs`` histogram (how hard the loop worked to keep this model on rails);
18
+ * ``duration_s`` — true end-to-end wall time including inference.
19
+
20
+ Model access pattern
21
+ ====================
22
+ Identical to ``scripts/bench_models.py --filegen``: installed gemma/qwen/llama
23
+ MLX models are discovered through the product's own model catalog + on-disk HF
24
+ checks, loaded with the real ``LLMRouter``, and unloaded afterwards. The tool
25
+ port is in-memory (canned results, nothing touches disk or network) — this is
26
+ a smoke of the LOOP over real model output, not of the tool implementations.
27
+
28
+ FAIL-OPEN by design
29
+ ===================
30
+ This is a scheduled/weekly report, never a CI gate:
31
+
32
+ * no models installed (or catalog import fails, e.g. on a CI runner) →
33
+ an honest "no models available — skipped (fail-open)" report, exit 0;
34
+ * a model that fails to load → a per-model skip entry, exit 0;
35
+ * any run-level crash → a skip report naming the error, exit 0;
36
+ * weak results are the finding, never a failure.
37
+
38
+ Non-zero exits happen only for real script errors (bad flags → argparse's 2).
39
+
40
+ Usage
41
+ =====
42
+ .venv/bin/python scripts/bench_agent_smoke.py # human table
43
+ .venv/bin/python scripts/bench_agent_smoke.py --json # JSON to stdout
44
+ .venv/bin/python scripts/bench_agent_smoke.py --tasks 1 --max-steps 6
45
+ .venv/bin/python scripts/bench_agent_smoke.py --model mlx-community/...
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import argparse
51
+ import asyncio
52
+ import json
53
+ import sys
54
+ import tempfile
55
+ import time
56
+ from pathlib import Path
57
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
58
+
59
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
60
+
61
+ from latticeai.core.agent import ( # noqa: E402
62
+ AgentDeps,
63
+ AgentRunContext,
64
+ AgentState,
65
+ SingleAgentRuntime,
66
+ )
67
+ from latticeai.core.agent_eval import classify_result # noqa: E402
68
+ from latticeai.core.agent_prompts import ( # noqa: E402
69
+ CRITIC_PROMPT,
70
+ EXECUTOR_PROMPT,
71
+ MEMORY_UPDATER_PROMPT,
72
+ PLANNER_PROMPT,
73
+ )
74
+ from latticeai.tools import ToolError # noqa: E402
75
+
76
+
77
+ # ── canonical smoke tasks ────────────────────────────────────────────────
78
+ # Small, file-flavored tasks users actually ask for. The tool port is canned,
79
+ # so the measurement is "can this model steer the real loop to completion",
80
+ # not "is the artifact beautiful".
81
+ SMOKE_TASKS: List[Tuple[str, str]] = [
82
+ ("haiku-file", "Write a short haiku about autumn into haiku.txt."),
83
+ ("hello-script", "Create a Python file hello.py that prints hello."),
84
+ (
85
+ "list-then-summarize",
86
+ "List the files in the workspace, then summarize what you see in one sentence.",
87
+ ),
88
+ ]
89
+
90
+ _SMOKE_MODEL_FAMILIES = ("gemma", "qwen", "llama")
91
+
92
+ _AUTO_POLICY = {
93
+ "auto_approve": True, "risk": "low", "shell": False, "network": False,
94
+ "destructive": False, "sandbox": False, "rollback": "none",
95
+ }
96
+
97
+
98
+ def discover_agent_models() -> List[Dict[str, str]]:
99
+ """Installed local MLX models — the exact discovery pattern of
100
+ ``scripts/bench_models.py --filegen`` (product catalog + on-disk HF
101
+ checks). Any import/probe failure is fail-open: an empty list, never an
102
+ exception. Messages go to stderr so ``--json`` stdout stays parseable."""
103
+ try:
104
+ from latticeai.models.router import (
105
+ _looks_like_hf_model_dir,
106
+ hf_cache_model_dir,
107
+ hf_model_dir,
108
+ )
109
+ from latticeai.services.model_catalog import ENGINE_MODEL_CATALOG
110
+ except Exception as exc: # noqa: BLE001 - discovery must never crash the report
111
+ print(
112
+ f"agent-smoke: model catalog unavailable ({exc}); no models discovered",
113
+ file=sys.stderr,
114
+ )
115
+ return []
116
+ found: List[Dict[str, str]] = []
117
+ for entry in ENGINE_MODEL_CATALOG.get("local_mlx", []):
118
+ model_id = str(entry.get("id") or "")
119
+ lowered = model_id.lower()
120
+ family = next((f for f in _SMOKE_MODEL_FAMILIES if f in lowered), None)
121
+ if not family:
122
+ continue
123
+ try:
124
+ downloaded = (
125
+ _looks_like_hf_model_dir(hf_model_dir(model_id))
126
+ or hf_cache_model_dir(model_id) is not None
127
+ )
128
+ except Exception: # noqa: BLE001
129
+ downloaded = False
130
+ if downloaded:
131
+ found.append({"id": model_id, "family": family})
132
+ return found
133
+
134
+
135
+ # ── HTTP-less single-agent runtime over an in-memory tool port ───────────
136
+
137
+ class _SmokeReq:
138
+ conversation_id = None
139
+ temperature = 0.2
140
+ workspace_id = None
141
+ source = "agent_smoke"
142
+
143
+ def __init__(self, message: str) -> None:
144
+ self.message = message
145
+
146
+
147
+ def _build_smoke_deps(
148
+ generate_as: Callable[..., Awaitable[Any]],
149
+ tool_log: List[Dict[str, Any]],
150
+ ) -> AgentDeps:
151
+ """Real production prompts + a canned in-memory tool port.
152
+
153
+ The port mirrors the fake in ``latticeai.core.agent_eval``: it records
154
+ calls, fails a pathless write like the real dispatcher, and never touches
155
+ disk or network. All tools are auto-approve — governance behaviour is
156
+ agent_eval's job; this smoke measures loop mechanics over real inference.
157
+ """
158
+
159
+ async def generate(**kwargs):
160
+ return '{"action": "noop"}'
161
+
162
+ def execute_tool(name: str, args: dict) -> dict:
163
+ if name in ("write_file", "generate_file") and not args.get("path"):
164
+ raise ToolError(f"{name} requires args.path")
165
+ tool_log.append({"name": name, "args": args})
166
+ if name == "list_dir":
167
+ return {"ok": True, "entries": ["README.md", "haiku.txt", "hello.py"]}
168
+ if name == "read_file":
169
+ return {"ok": True, "path": args.get("path", ""), "content": "smoke fixture file"}
170
+ return {"ok": True, "path": args.get("path", "")}
171
+
172
+ return AgentDeps(
173
+ generate_as=generate_as,
174
+ generate=generate,
175
+ execute_tool=execute_tool,
176
+ policy_for=lambda name, args: dict(_AUTO_POLICY),
177
+ risk_level=lambda p: p["risk"],
178
+ check_role=lambda name, user: None,
179
+ tool_governance={
180
+ "write_file": dict(_AUTO_POLICY),
181
+ "read_file": dict(_AUTO_POLICY),
182
+ "list_dir": dict(_AUTO_POLICY),
183
+ "generate_file": dict(_AUTO_POLICY),
184
+ "knowledge_graph_ingest": dict(_AUTO_POLICY),
185
+ "knowledge_graph_search": dict(_AUTO_POLICY),
186
+ },
187
+ file_create_actions=frozenset({"write_file", "generate_file"}),
188
+ recent_chat_context=lambda **kw: "",
189
+ clear_history=lambda keep: {"ok": True},
190
+ knowledge_save=lambda *a, **kw: None,
191
+ audit=lambda *a, **kw: None,
192
+ planner_prompt=PLANNER_PROMPT,
193
+ executor_prompt=EXECUTOR_PROMPT,
194
+ critic_prompt=CRITIC_PROMPT,
195
+ memory_updater_prompt=MEMORY_UPDATER_PROMPT,
196
+ agent_root=Path(tempfile.gettempdir()) / "agent-smoke",
197
+ )
198
+
199
+
200
+ async def _run_one_task(
201
+ generate_as: Callable[..., Awaitable[Any]],
202
+ task_id: str,
203
+ prompt: str,
204
+ *,
205
+ max_steps: int = 8,
206
+ model_id: Optional[str] = None,
207
+ ) -> Dict[str, Any]:
208
+ """Drive one task through the real state machine; reduce to a report row."""
209
+ tool_log: List[Dict[str, Any]] = []
210
+ runtime = SingleAgentRuntime(_build_smoke_deps(generate_as, tool_log))
211
+ ctx = AgentRunContext()
212
+ ctx.state = AgentState.PLANNING
213
+ ctx.executing_model = model_id
214
+ ctx.reviewing_model = model_id
215
+ req = _SmokeReq(prompt)
216
+
217
+ t0 = time.perf_counter()
218
+ await runtime.plan(ctx, req, "en", "smoke@local", model_id=model_id)
219
+ # The operator launched this run deliberately — the human approval the
220
+ # gate asks for. The gate itself (and denial paths) are covered by
221
+ # agent_eval; a real model's free-form plan must not dead-end the smoke.
222
+ runtime.approve(ctx, "smoke@local", approved_by_human=True)
223
+ if ctx.state == AgentState.EXECUTING:
224
+ await runtime.run_to_completion(
225
+ ctx, req, "en", "smoke@local", max_steps=max_steps, max_retry=2
226
+ )
227
+ duration = round(time.perf_counter() - t0, 2)
228
+
229
+ summary = ctx.trace.summary()
230
+ executed = [call["name"] for call in tool_log]
231
+ return {
232
+ "task": task_id,
233
+ "final_state": ctx.state.value,
234
+ "result_class": classify_result(ctx.state.value, ctx.trace.events, summary, executed),
235
+ "steps": summary["llm_calls"],
236
+ "parse_errors": summary["parse_errors"],
237
+ "parse_recovered": summary["parse_recovered"],
238
+ "repairs_total": sum((summary.get("repairs") or {}).values()),
239
+ "repairs": summary.get("repairs") or {},
240
+ "tool_calls": len(tool_log),
241
+ "duration_s": duration,
242
+ }
243
+
244
+
245
+ def _make_router_generate_as(router: Any, model_id: str) -> Callable[..., Awaitable[str]]:
246
+ """Bridge the runtime's generate_as port to the real LLMRouter (the same
247
+ ``generate_as`` call shape bench_models' filegen mode uses)."""
248
+
249
+ async def generate_as(_model_id, message, context, max_tokens, temperature):
250
+ return str(
251
+ await router.generate_as(
252
+ _model_id or model_id,
253
+ message=message,
254
+ context=context,
255
+ max_tokens=max_tokens,
256
+ temperature=temperature,
257
+ )
258
+ )
259
+
260
+ return generate_as
261
+
262
+
263
+ async def _smoke_run_models(
264
+ models: List[Dict[str, str]],
265
+ tasks: List[Tuple[str, str]],
266
+ max_steps: int,
267
+ ) -> List[Dict[str, Any]]:
268
+ from latticeai.models.router import LLMRouter
269
+
270
+ router = LLMRouter()
271
+ results: List[Dict[str, Any]] = []
272
+ for model in models:
273
+ model_id = model["id"]
274
+ try:
275
+ await router.load_model(model_id)
276
+ except Exception as exc: # noqa: BLE001 - fail-open per model
277
+ results.append({
278
+ "model": model_id, "family": model["family"],
279
+ "skipped": True, "reason": f"load failed: {exc}",
280
+ })
281
+ continue
282
+ generate_as = _make_router_generate_as(router, model_id)
283
+ rows: List[Dict[str, Any]] = []
284
+ for task_id, prompt in tasks:
285
+ try:
286
+ rows.append(await _run_one_task(
287
+ generate_as, task_id, prompt,
288
+ max_steps=max_steps, model_id=model_id,
289
+ ))
290
+ except Exception as exc: # noqa: BLE001 - one broken task never sinks the report
291
+ rows.append({
292
+ "task": task_id, "final_state": "HARNESS_ERROR",
293
+ "result_class": "failed", "error": str(exc),
294
+ })
295
+ results.append({
296
+ "model": model_id,
297
+ "family": model["family"],
298
+ "tasks": rows,
299
+ "tasks_total": len(rows),
300
+ "completed": sum(1 for r in rows if r.get("final_state") == "DONE"),
301
+ "duration_s": round(sum(float(r.get("duration_s") or 0.0) for r in rows), 2),
302
+ })
303
+ try:
304
+ router.unload_model(model_id)
305
+ except Exception: # noqa: BLE001
306
+ pass
307
+ return results
308
+
309
+
310
+ def run_agent_smoke(
311
+ models: Optional[List[Dict[str, str]]] = None,
312
+ tasks: Optional[List[Tuple[str, str]]] = None,
313
+ max_steps: int = 8,
314
+ ) -> Dict[str, Any]:
315
+ """Weekly per-model agent-loop report. Fail-open: never raises, never gates."""
316
+ if models is None:
317
+ models = discover_agent_models()
318
+ selected_tasks = list(tasks or SMOKE_TASKS)
319
+ if not models:
320
+ return {
321
+ "mode": "agent-smoke",
322
+ "status": "skipped",
323
+ "reason": (
324
+ "no models available — install a local gemma/qwen/llama model "
325
+ "via the app's model picker, then re-run"
326
+ ),
327
+ "models": [],
328
+ }
329
+ try:
330
+ results = asyncio.run(_smoke_run_models(models, selected_tasks, max_steps))
331
+ except Exception as exc: # noqa: BLE001 - fail-open at the run level too
332
+ return {
333
+ "mode": "agent-smoke", "status": "skipped",
334
+ "reason": f"smoke run failed: {exc}", "models": [],
335
+ }
336
+ return {"mode": "agent-smoke", "status": "ok", "models": results}
337
+
338
+
339
+ def format_smoke_report(report: Dict[str, Any]) -> str:
340
+ lines = [
341
+ "Weekly agent-loop smoke (real models through SingleAgentRuntime)",
342
+ "=" * 72,
343
+ ]
344
+ if report.get("status") == "skipped":
345
+ lines.append(f"no models available — skipped (fail-open): {report.get('reason')}")
346
+ lines.append("=" * 72)
347
+ return "\n".join(lines)
348
+ for model in report.get("models", []):
349
+ if model.get("skipped"):
350
+ lines.append(f" {model['model']}: SKIPPED — {model.get('reason')}")
351
+ continue
352
+ lines.append(
353
+ f" {model['model']} "
354
+ f"(completed {model['completed']}/{model['tasks_total']}, "
355
+ f"{model['duration_s']}s)"
356
+ )
357
+ for row in model.get("tasks", []):
358
+ if row.get("final_state") == "HARNESS_ERROR":
359
+ lines.append(f" {row['task']:<22} HARNESS_ERROR {row.get('error')}")
360
+ continue
361
+ lines.append(
362
+ f" {row['task']:<22} {row['final_state']:<13} "
363
+ f"steps={row['steps']} parse_errors={row['parse_errors']} "
364
+ f"repairs={row['repairs_total']} tools={row['tool_calls']} "
365
+ f"{row['duration_s']}s"
366
+ )
367
+ lines.append("=" * 72)
368
+ lines.append(
369
+ "note: fail-open weekly report — weak results are the finding, never a failure"
370
+ )
371
+ return "\n".join(lines)
372
+
373
+
374
+ def main(argv: List[str] | None = None) -> int:
375
+ parser = argparse.ArgumentParser(
376
+ description="Weekly real-model agent-loop smoke (fail-open, never a gate)"
377
+ )
378
+ parser.add_argument(
379
+ "--json", dest="json_out", action="store_true",
380
+ help="print the machine-readable report JSON to stdout",
381
+ )
382
+ parser.add_argument(
383
+ "--tasks", type=int, default=len(SMOKE_TASKS),
384
+ help=f"how many smoke tasks to run per model (1-{len(SMOKE_TASKS)}, default all)",
385
+ )
386
+ parser.add_argument(
387
+ "--max-steps", type=int, default=8, help="agent-loop step budget per task",
388
+ )
389
+ parser.add_argument(
390
+ "--model", help="restrict the run to one installed model id",
391
+ )
392
+ args = parser.parse_args(argv)
393
+
394
+ count = max(1, min(int(args.tasks), len(SMOKE_TASKS)))
395
+ models = discover_agent_models()
396
+ if args.model:
397
+ models = [m for m in models if m["id"] == args.model]
398
+ report = run_agent_smoke(
399
+ models=models, tasks=SMOKE_TASKS[:count], max_steps=max(2, int(args.max_steps)),
400
+ )
401
+ if args.json_out:
402
+ print(json.dumps(report, ensure_ascii=False, indent=2))
403
+ else:
404
+ print(format_smoke_report(report))
405
+ # FAIL-OPEN: missing or weak models never produce a non-zero exit.
406
+ return 0
407
+
408
+
409
+ if __name__ == "__main__":
410
+ raise SystemExit(main())
@@ -6,7 +6,7 @@ const root = process.cwd();
6
6
  const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
7
7
  const version = pkg.version;
8
8
  const releaseDir = `output/release/v${version}`;
9
- const releaseTheme = "Closed Loops";
9
+ const releaseTheme = "Closed Gaps";
10
10
  const title = `${version} — ${releaseTheme}`;
11
11
  const escapedVersion = version.replaceAll(".", "\\.");
12
12
 
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env python3
2
+ """Funnel soft gate (review Wave 3.3) — funnel metrics as an advisory verdict.
3
+
4
+ Reads the same ``funnel_metrics.json`` production writes and grades it through
5
+ ``FunnelMetricsService.snapshot()`` — no rate logic is duplicated here. Two
6
+ funnel-health rates carry WARN thresholds:
7
+
8
+ * ``code_only_rate`` — WARN above 0.05 (goal: >95% of recognized file
9
+ requests deliver a real artifact, not a code/prose-only answer);
10
+ * ``needs_review_rate`` — WARN above 0.30 (verifier fail-closed is healthy,
11
+ but a third of runs ending NEEDS_REVIEW means verification is starving).
12
+
13
+ Rates whose denominator is still zero come back ``None`` from the snapshot;
14
+ they are reported as "no data" and never warn — a fresh install stays green.
15
+ ``real_file_rate``, ``approval_resume_rate`` and TTFV are shown as
16
+ informational context only.
17
+
18
+ Exit codes
19
+ ==========
20
+ * default — advisory: ALWAYS ``0``; warnings are printed, not enforced;
21
+ * ``--strict`` — CI opt-in: ``1`` when any WARN fired ("no data" never
22
+ fails, even under --strict);
23
+ * bad flags — argparse's usual ``2`` (the only real script error).
24
+
25
+ Usage
26
+ =====
27
+ .venv/bin/python scripts/funnel_soft_gate.py # advisory report
28
+ .venv/bin/python scripts/funnel_soft_gate.py --strict # CI gate opt-in
29
+ .venv/bin/python scripts/funnel_soft_gate.py --json # machine output
30
+ .venv/bin/python scripts/funnel_soft_gate.py --path /tmp/m.json
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import json
37
+ import os
38
+ import sys
39
+ from pathlib import Path
40
+ from typing import Any, Dict, List, Optional
41
+
42
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
43
+
44
+ from latticeai.services.funnel_metrics import FunnelMetricsService # noqa: E402
45
+
46
+ # Advisory thresholds (overridable per run via flags). WARN fires strictly
47
+ # ABOVE the threshold, so a rate sitting exactly on the goal boundary passes.
48
+ CODE_ONLY_WARN_THRESHOLD = 0.05
49
+ NEEDS_REVIEW_WARN_THRESHOLD = 0.30
50
+
51
+
52
+ def default_metrics_path() -> Path:
53
+ """The exact file production writes: ``Config.from_env`` data-dir pattern
54
+ (``LATTICEAI_DATA_DIR`` env or ``~/.ltcai``) + the ``funnel_metrics.json``
55
+ filename from ``latticeai/runtime/persistence_runtime.py``."""
56
+ data_dir = Path(os.environ.get("LATTICEAI_DATA_DIR") or (Path.home() / ".ltcai"))
57
+ return data_dir / "funnel_metrics.json"
58
+
59
+
60
+ def evaluate_snapshot(
61
+ snapshot: Dict[str, Any],
62
+ *,
63
+ code_only_warn: float = CODE_ONLY_WARN_THRESHOLD,
64
+ needs_review_warn: float = NEEDS_REVIEW_WARN_THRESHOLD,
65
+ ) -> Dict[str, Any]:
66
+ """Grade a ``FunnelMetricsService.snapshot()`` payload into verdicts."""
67
+ rates = snapshot.get("rates") or {}
68
+ checks: List[Dict[str, Any]] = []
69
+
70
+ def check(name: str, threshold: float, goal: str) -> None:
71
+ rate: Optional[float] = rates.get(name)
72
+ if rate is None:
73
+ verdict, detail = "no_data", "no denominator recorded yet"
74
+ elif rate > threshold:
75
+ verdict, detail = "warn", f"{rate} > {threshold} — {goal}"
76
+ else:
77
+ verdict, detail = "ok", f"{rate} <= {threshold}"
78
+ checks.append({
79
+ "name": name, "rate": rate, "threshold": threshold,
80
+ "verdict": verdict, "detail": detail,
81
+ })
82
+
83
+ check("code_only_rate", code_only_warn,
84
+ "goal: >95% of file requests deliver real files")
85
+ check("needs_review_rate", needs_review_warn,
86
+ "goal: <30% of agent runs end NEEDS_REVIEW")
87
+
88
+ warnings = [c["name"] for c in checks if c["verdict"] == "warn"]
89
+ if warnings:
90
+ status = "warn"
91
+ elif all(c["verdict"] == "no_data" for c in checks):
92
+ status = "no_data"
93
+ else:
94
+ status = "ok"
95
+ return {
96
+ "status": status,
97
+ "warnings": warnings,
98
+ "checks": checks,
99
+ # Informational context — never gated.
100
+ "info": {
101
+ "real_file_rate": rates.get("real_file_rate"),
102
+ "approval_resume_rate": rates.get("approval_resume_rate"),
103
+ "ttfv_seconds": snapshot.get("ttfv_seconds"),
104
+ "counters": snapshot.get("counters") or {},
105
+ },
106
+ }
107
+
108
+
109
+ def format_report(payload: Dict[str, Any]) -> str:
110
+ lines = [
111
+ f"Funnel soft gate (advisory) — {payload['path']}",
112
+ "=" * 72,
113
+ ]
114
+ for check in payload["checks"]:
115
+ rate = "no data" if check["rate"] is None else str(check["rate"])
116
+ verdict = {"warn": "WARN", "ok": "ok", "no_data": "-"}[check["verdict"]]
117
+ lines.append(
118
+ f" {check['name']:<20} {rate:<8} {verdict:<5} {check['detail']}"
119
+ )
120
+ info = payload["info"]
121
+ lines.append("-" * 72)
122
+ lines.append(
123
+ f" info: real_file_rate={info['real_file_rate']} "
124
+ f"approval_resume_rate={info['approval_resume_rate']} "
125
+ f"ttfv_seconds={info['ttfv_seconds']}"
126
+ )
127
+ lines.append("=" * 72)
128
+ if payload["status"] == "warn":
129
+ mode = (
130
+ "STRICT — exiting 1" if payload["strict"]
131
+ else "advisory — exit 0; opt into enforcement with --strict"
132
+ )
133
+ lines.append(f"verdict: WARN ({', '.join(payload['warnings'])}) [{mode}]")
134
+ elif payload["status"] == "no_data":
135
+ lines.append("verdict: no data yet — nothing to grade (exit 0)")
136
+ else:
137
+ lines.append("verdict: OK — funnel rates within goals")
138
+ return "\n".join(lines)
139
+
140
+
141
+ def main(argv: List[str] | None = None) -> int:
142
+ parser = argparse.ArgumentParser(
143
+ description="Advisory funnel-metrics gate (warn on funnel-health regressions)"
144
+ )
145
+ parser.add_argument(
146
+ "--path",
147
+ help="metrics JSON path (default: the production data-dir funnel_metrics.json)",
148
+ )
149
+ parser.add_argument(
150
+ "--strict", action="store_true",
151
+ help="exit 1 when any WARN fired (CI opt-in; 'no data' still exits 0)",
152
+ )
153
+ parser.add_argument(
154
+ "--json", dest="json_out", action="store_true",
155
+ help="print the machine-readable verdict JSON to stdout",
156
+ )
157
+ parser.add_argument(
158
+ "--code-only-warn", type=float, default=CODE_ONLY_WARN_THRESHOLD,
159
+ help=f"code_only_rate WARN threshold (default {CODE_ONLY_WARN_THRESHOLD})",
160
+ )
161
+ parser.add_argument(
162
+ "--needs-review-warn", type=float, default=NEEDS_REVIEW_WARN_THRESHOLD,
163
+ help=f"needs_review_rate WARN threshold (default {NEEDS_REVIEW_WARN_THRESHOLD})",
164
+ )
165
+ args = parser.parse_args(argv)
166
+
167
+ path = Path(args.path) if args.path else default_metrics_path()
168
+ # A missing or corrupt file yields the service's zeroed state → all rates
169
+ # None → "no data" (never a crash, never a failure).
170
+ snapshot = FunnelMetricsService(path).snapshot()
171
+ result = evaluate_snapshot(
172
+ snapshot,
173
+ code_only_warn=args.code_only_warn,
174
+ needs_review_warn=args.needs_review_warn,
175
+ )
176
+ exit_code = 1 if (args.strict and result["status"] == "warn") else 0
177
+ payload = {
178
+ "mode": "funnel-soft-gate",
179
+ "path": str(path),
180
+ "strict": bool(args.strict),
181
+ "exit_code": exit_code,
182
+ **result,
183
+ }
184
+ if args.json_out:
185
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
186
+ else:
187
+ print(format_report(payload))
188
+ return exit_code
189
+
190
+
191
+ if __name__ == "__main__":
192
+ raise SystemExit(main())