ltcai 9.9.2 → 9.9.4

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 (109) hide show
  1. package/README.md +44 -39
  2. package/docs/CHANGELOG.md +109 -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 +47 -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/curator.py +141 -0
  14. package/lattice_brain/graph/discovery_index.py +8 -1
  15. package/lattice_brain/graph/documents.py +111 -0
  16. package/lattice_brain/graph/fusion.py +179 -0
  17. package/lattice_brain/graph/ingest.py +43 -9
  18. package/lattice_brain/graph/projection.py +382 -37
  19. package/lattice_brain/graph/retrieval.py +71 -4
  20. package/lattice_brain/graph/retrieval_policy.py +174 -0
  21. package/lattice_brain/graph/retrieval_vector.py +122 -2
  22. package/lattice_brain/ingestion.py +66 -0
  23. package/lattice_brain/portability.py +41 -12
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/automation_intelligence.py +159 -0
  27. package/latticeai/api/browser.py +15 -4
  28. package/latticeai/api/chat.py +33 -2
  29. package/latticeai/api/chat_agent_http.py +363 -6
  30. package/latticeai/api/chat_contracts.py +7 -1
  31. package/latticeai/api/chat_helpers.py +147 -0
  32. package/latticeai/api/chat_intents.py +217 -10
  33. package/latticeai/api/chat_stream.py +93 -3
  34. package/latticeai/api/funnel_metrics.py +31 -0
  35. package/latticeai/api/knowledge_graph.py +65 -1
  36. package/latticeai/api/local_files.py +90 -0
  37. package/latticeai/api/setup.py +134 -1
  38. package/latticeai/api/tools.py +33 -3
  39. package/latticeai/app_factory.py +14 -0
  40. package/latticeai/core/agent.py +355 -18
  41. package/latticeai/core/agent_eval.py +107 -0
  42. package/latticeai/core/enterprise.py +5 -0
  43. package/latticeai/core/file_generation.py +404 -2
  44. package/latticeai/core/legacy_compatibility.py +1 -1
  45. package/latticeai/core/marketplace.py +1 -1
  46. package/latticeai/core/run_store.py +243 -0
  47. package/latticeai/core/workspace_os.py +1 -1
  48. package/latticeai/models/router.py +25 -12
  49. package/latticeai/runtime/persistence_runtime.py +19 -1
  50. package/latticeai/runtime/router_registration.py +8 -1
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/automation_execution.py +266 -0
  54. package/latticeai/services/automation_intelligence.py +7 -0
  55. package/latticeai/services/command_center.py +110 -2
  56. package/latticeai/services/folder_watch.py +396 -0
  57. package/latticeai/services/funnel_metrics.py +210 -0
  58. package/latticeai/services/product_readiness.py +1 -1
  59. package/latticeai/services/search_service.py +51 -5
  60. package/latticeai/setup/demo_corpus.py +98 -0
  61. package/latticeai/tools/filesystem.py +38 -1
  62. package/package.json +1 -1
  63. package/scripts/bench_agent_smoke.py +410 -0
  64. package/scripts/bench_models.py +234 -5
  65. package/scripts/check_current_release_docs.mjs +1 -1
  66. package/scripts/funnel_soft_gate.py +192 -0
  67. package/src-tauri/Cargo.lock +1 -1
  68. package/src-tauri/Cargo.toml +1 -1
  69. package/src-tauri/tauri.conf.json +1 -1
  70. package/static/app/asset-manifest.json +29 -27
  71. package/static/app/assets/Act-BCmTU0E2.js +1 -0
  72. package/static/app/assets/{Brain-BnGbytHa.js → Brain-CTnjox7w.js} +2 -2
  73. package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
  74. package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
  75. package/static/app/assets/{Capture-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
  76. package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
  77. package/static/app/assets/Library-pKCK0_tk.js +1 -0
  78. package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
  79. package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
  80. package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
  81. package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
  82. package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
  83. package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
  84. package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
  85. package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
  86. package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
  87. package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
  88. package/static/app/assets/index-7FAfYm4v.css +2 -0
  89. package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
  90. package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
  91. package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
  92. package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
  93. package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
  94. package/static/app/assets/shield-alert-BftATuAA.js +1 -0
  95. package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
  96. package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
  97. package/static/app/assets/utils-SJUNVOj5.js +7 -0
  98. package/static/app/index.html +4 -4
  99. package/static/sw.js +1 -1
  100. package/static/app/assets/Act-Cx_gZNbE.js +0 -1
  101. package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
  102. package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
  103. package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
  104. package/static/app/assets/Library-PcfVUco6.js +0 -1
  105. package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
  106. package/static/app/assets/download-C7yxYHvz.js +0 -1
  107. package/static/app/assets/index-DU78Kogz.css +0 -2
  108. package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
  109. package/static/app/assets/utils-Dh-Dwtkb.js +0 -7
@@ -4,6 +4,22 @@ from __future__ import annotations
4
4
 
5
5
  from ._kg_common import * # noqa: F403,F401
6
6
 
7
+ # ── promotion review mode (review 2026-07-25 Wave 4) ─────────────────────────
8
+ # When enabled, curate() parks would-be Topic promotions in graph_meta for a
9
+ # human decision instead of writing them immediately. Explicit review_mode=
10
+ # argument wins; otherwise this env opt-in decides; default stays auto-promote.
11
+ _PROMOTION_REVIEW_ENV = "LATTICEAI_GRAPH_PROMOTION_REVIEW"
12
+ _PENDING_PROMOTIONS_KEY = "pending_promotions"
13
+ _PENDING_PROMOTIONS_CAP = 100
14
+
15
+ # graph_meta stamp written by an applied (dry_run=False) noise-curate run; the
16
+ # Command Center hygiene advisory reads it to pace its suggestion (Wave 2.5).
17
+ _LAST_NOISE_CURATE_KEY = "last_noise_curate_at"
18
+
19
+
20
+ def _promotion_review_default() -> bool:
21
+ return os.getenv(_PROMOTION_REVIEW_ENV, "").strip().lower() in ("1", "true", "yes")
22
+
7
23
 
8
24
  class KnowledgeGraphProjectionMixin:
9
25
  _FTS_SQL = """
@@ -408,7 +424,11 @@ class KnowledgeGraphProjectionMixin:
408
424
  )
409
425
 
410
426
  def curate(
411
- self, *, max_documents: int = 200, max_new_nodes: int = 8
427
+ self,
428
+ *,
429
+ max_documents: int = 200,
430
+ max_new_nodes: int = 8,
431
+ review_mode: Optional[bool] = None,
412
432
  ) -> Dict[str, Any]:
413
433
  """On-demand graph curation (T4.4 — graph_curator goes live).
414
434
 
@@ -418,6 +438,14 @@ class KnowledgeGraphProjectionMixin:
418
438
  Topic nodes (with MENTIONS edges back to their sources and a real
419
439
  importance_score in nodes_v2). Explicit and observable — the result
420
440
  reports everything promoted AND everything skipped, with reasons.
441
+
442
+ ``review_mode`` (review 2026-07-25 Wave 4): when True, nothing is
443
+ written — the would-be promotions are parked in ``graph_meta`` as
444
+ ``pending_promotions`` for a human decision via
445
+ :meth:`apply_pending_promotions` / :meth:`reject_pending_promotions`.
446
+ Explicit argument wins; ``None`` falls back to the
447
+ ``LATTICEAI_GRAPH_PROMOTION_REVIEW`` env opt-in; default stays the
448
+ historical auto-promote behavior.
421
449
  """
422
450
  from .curator import auto_build_graph_overlay
423
451
 
@@ -464,46 +492,37 @@ class KnowledgeGraphProjectionMixin:
464
492
  existing_node_labels=existing_labels,
465
493
  max_new_nodes=max(1, min(int(max_new_nodes), 50)),
466
494
  )
495
+ valid_ids = {row["id"] for row in rows}
496
+ review = review_mode if review_mode is not None else _promotion_review_default()
497
+ if review:
498
+ proposed_at = _now()
499
+ proposed = [
500
+ {
501
+ "id": f"topic:{_slug(promo['label'])}",
502
+ "label": promo["label"],
503
+ "importance": promo["importance"],
504
+ "aliases": promo["aliases"],
505
+ "sources": [s for s in promo["sources"][:10] if s in valid_ids],
506
+ "proposed_at": proposed_at,
507
+ }
508
+ for promo in overlay["promotions"]
509
+ ]
510
+ with self._connect() as conn:
511
+ merged = self._merge_pending_promotions(conn, proposed)
512
+ return {
513
+ "status": "pending_review",
514
+ "documents_scanned": len(documents),
515
+ "candidates_total": overlay["candidates_total"],
516
+ "pending": proposed,
517
+ "pending_total": len(merged),
518
+ "skipped": overlay["skipped"][:50],
519
+ "skipped_total": len(overlay["skipped"]),
520
+ }
467
521
  promoted: List[Dict[str, Any]] = []
468
522
  with self._connect() as conn:
469
- valid_ids = {row["id"] for row in rows}
470
523
  for promo in overlay["promotions"]:
471
- topic_id = f"topic:{_slug(promo['label'])}"
472
- self._upsert_node(
473
- conn,
474
- topic_id,
475
- "Topic",
476
- promo["label"],
477
- metadata={
478
- "curated": True,
479
- "importance": promo["importance"],
480
- "aliases": promo["aliases"],
481
- "source": "graph_curator",
482
- },
483
- )
484
- conn.execute(
485
- "UPDATE nodes_v2 SET importance_score=? WHERE id=?",
486
- (float(promo["importance"]), topic_id),
487
- )
488
- linked = 0
489
- for source_id in promo["sources"][:10]:
490
- if source_id in valid_ids:
491
- self._upsert_edge(
492
- conn,
493
- source_id,
494
- topic_id,
495
- "MENTIONS",
496
- weight=0.6,
497
- metadata={"source": "graph_curator"},
498
- )
499
- linked += 1
500
524
  promoted.append(
501
- {
502
- "node_id": topic_id,
503
- "label": promo["label"],
504
- "importance": promo["importance"],
505
- "linked_sources": linked,
506
- }
525
+ self._write_promotion(conn, promo, valid_source_ids=valid_ids)
507
526
  )
508
527
  return {
509
528
  "status": "ok",
@@ -514,6 +533,332 @@ class KnowledgeGraphProjectionMixin:
514
533
  "skipped_total": len(overlay["skipped"]),
515
534
  }
516
535
 
536
+ def _write_promotion(
537
+ self,
538
+ conn: sqlite3.Connection,
539
+ promo: Dict[str, Any],
540
+ *,
541
+ valid_source_ids: Optional[set] = None,
542
+ ) -> Dict[str, Any]:
543
+ """Write one curator promotion: Topic node + importance + MENTIONS edges.
544
+
545
+ Single write path shared by direct ``curate()`` and
546
+ :meth:`apply_pending_promotions`, so a human-approved promotion lands
547
+ exactly like an auto-promoted one. ``valid_source_ids`` restricts the
548
+ linkable sources to this curate run's scanned rows; when ``None``
549
+ (apply-after-review), each stored source is checked for existence so a
550
+ node deleted between propose and apply is skipped, not an error.
551
+ """
552
+ topic_id = str(promo.get("id") or f"topic:{_slug(str(promo['label']))}")
553
+ self._upsert_node(
554
+ conn,
555
+ topic_id,
556
+ "Topic",
557
+ str(promo["label"]),
558
+ metadata={
559
+ "curated": True,
560
+ "importance": promo["importance"],
561
+ "aliases": list(promo.get("aliases") or []),
562
+ "source": "graph_curator",
563
+ },
564
+ )
565
+ conn.execute(
566
+ "UPDATE nodes_v2 SET importance_score=? WHERE id=?",
567
+ (float(promo["importance"]), topic_id),
568
+ )
569
+ linked = 0
570
+ for source_id in list(promo.get("sources") or [])[:10]:
571
+ if valid_source_ids is not None:
572
+ if source_id not in valid_source_ids:
573
+ continue
574
+ elif not conn.execute(
575
+ "SELECT 1 FROM nodes WHERE id=?", (source_id,)
576
+ ).fetchone():
577
+ continue
578
+ self._upsert_edge(
579
+ conn,
580
+ source_id,
581
+ topic_id,
582
+ "MENTIONS",
583
+ weight=0.6,
584
+ metadata={"source": "graph_curator"},
585
+ )
586
+ linked += 1
587
+ return {
588
+ "node_id": topic_id,
589
+ "label": promo["label"],
590
+ "importance": promo["importance"],
591
+ "linked_sources": linked,
592
+ }
593
+
594
+ # ── pending promotion queue (review 2026-07-25 Wave 4) ───────────────────
595
+
596
+ def _read_pending_promotions(
597
+ self, conn: sqlite3.Connection
598
+ ) -> List[Dict[str, Any]]:
599
+ try:
600
+ row = conn.execute(
601
+ "SELECT value FROM graph_meta WHERE key=?",
602
+ (_PENDING_PROMOTIONS_KEY,),
603
+ ).fetchone()
604
+ except sqlite3.Error:
605
+ return []
606
+ if not row or not row["value"]:
607
+ return []
608
+ try:
609
+ parsed = json.loads(row["value"])
610
+ except (TypeError, ValueError):
611
+ return []
612
+ if not isinstance(parsed, list):
613
+ return []
614
+ return [
615
+ item for item in parsed if isinstance(item, dict) and item.get("id")
616
+ ]
617
+
618
+ def _store_pending_promotions(
619
+ self, conn: sqlite3.Connection, entries: List[Dict[str, Any]]
620
+ ) -> None:
621
+ conn.execute(
622
+ "INSERT OR REPLACE INTO graph_meta(key, value) VALUES (?, ?)",
623
+ (_PENDING_PROMOTIONS_KEY, json.dumps(entries, ensure_ascii=False)),
624
+ )
625
+
626
+ def _merge_pending_promotions(
627
+ self, conn: sqlite3.Connection, proposed: List[Dict[str, Any]]
628
+ ) -> List[Dict[str, Any]]:
629
+ """Merge new proposals into the stored queue (dedupe by id, cap 100)."""
630
+ merged: Dict[str, Dict[str, Any]] = {}
631
+ for item in self._read_pending_promotions(conn) + list(proposed):
632
+ merged[str(item["id"])] = item # newest proposal wins per id
633
+ entries = list(merged.values())[-_PENDING_PROMOTIONS_CAP:]
634
+ self._store_pending_promotions(conn, entries)
635
+ return entries
636
+
637
+ def pending_promotions(self) -> List[Dict[str, Any]]:
638
+ """List promotions waiting for a human decision (review mode)."""
639
+ with self._connect() as conn:
640
+ return self._read_pending_promotions(conn)
641
+
642
+ def apply_pending_promotions(
643
+ self, ids: Optional[List[str]] = None
644
+ ) -> Dict[str, Any]:
645
+ """Apply stored pending promotions (all of them when ``ids`` is None).
646
+
647
+ Uses the exact node-writing path as direct ``curate()`` via
648
+ :meth:`_write_promotion`; applied entries leave the queue.
649
+ """
650
+ wanted = None if ids is None else {str(item) for item in ids}
651
+ applied: List[Dict[str, Any]] = []
652
+ remaining: List[Dict[str, Any]] = []
653
+ with self._connect() as conn:
654
+ for promo in self._read_pending_promotions(conn):
655
+ if wanted is not None and str(promo.get("id")) not in wanted:
656
+ remaining.append(promo)
657
+ continue
658
+ applied.append(self._write_promotion(conn, promo))
659
+ self._store_pending_promotions(conn, remaining)
660
+ return {"status": "ok", "applied": applied, "remaining": len(remaining)}
661
+
662
+ def reject_pending_promotions(
663
+ self, ids: Optional[List[str]] = None
664
+ ) -> Dict[str, Any]:
665
+ """Drop pending promotions without writing (all when ``ids`` is None)."""
666
+ wanted = None if ids is None else {str(item) for item in ids}
667
+ rejected: List[str] = []
668
+ remaining: List[Dict[str, Any]] = []
669
+ with self._connect() as conn:
670
+ for promo in self._read_pending_promotions(conn):
671
+ if wanted is not None and str(promo.get("id")) not in wanted:
672
+ remaining.append(promo)
673
+ continue
674
+ rejected.append(str(promo.get("id")))
675
+ self._store_pending_promotions(conn, remaining)
676
+ return {"status": "ok", "rejected": rejected, "remaining": len(remaining)}
677
+
678
+ _NOISE_CONTENT_TYPES = (
679
+ "Document",
680
+ "File",
681
+ "CodeFile",
682
+ "Message",
683
+ "AIResponse",
684
+ "Chat",
685
+ "Page",
686
+ "Slide",
687
+ "Spreadsheet",
688
+ )
689
+ _NOISE_CONCEPT_TYPES = ("Concept", "Feature", "Topic", "Code", "Error")
690
+
691
+ def curate_noise(
692
+ self,
693
+ *,
694
+ dry_run: bool = True,
695
+ max_df_ratio: float = 0.8,
696
+ min_doc_frequency: int = 1,
697
+ min_corpus_docs: int = 5,
698
+ normalize_verbs: bool = True,
699
+ max_removals: int = 200,
700
+ ) -> Dict[str, Any]:
701
+ """Noise-reduction curation job (backlog #10, review §7.2 D).
702
+
703
+ (a) Removes heuristic concept nodes (``auto_extracted`` /
704
+ ``graph_curator``-promoted) whose document frequency marks them as
705
+ noise: ubiquitous (low IDF — linked from more than ``max_df_ratio`` of
706
+ content docs once the corpus has ``min_corpus_docs``) or below the
707
+ ``min_doc_frequency`` floor. Explicitly user-created nodes are never
708
+ touched, whatever their stats.
709
+
710
+ (b) Normalizes free-string relation verbs on the legacy edge table via
711
+ the ko/en dictionary in :mod:`lattice_brain.graph.curator`
712
+ ('만들다/만든/creates' → 'created', …), merging rows that collide
713
+ after the rename.
714
+
715
+ ``dry_run=True`` (the default) only *reports* what would change.
716
+ """
717
+ from .curator import (
718
+ build_relation_verb_index,
719
+ plan_concept_noise_reduction,
720
+ plan_relation_normalization,
721
+ )
722
+
723
+ max_removals = max(0, int(max_removals))
724
+ # Operate on the legacy write tables directly: they are the mutation
725
+ # target, and raw free-string verbs only exist there (the v4 write
726
+ # door normalizes new edges; the kgv2_* read views collapse
727
+ # legacy_type and would hide exactly the rows this job cleans up).
728
+ nt, et = "nodes", "edges"
729
+ with self._connect() as conn:
730
+ content_ph = ",".join("?" for _ in self._NOISE_CONTENT_TYPES)
731
+ total_docs = conn.execute(
732
+ f"SELECT COUNT(*) AS c FROM {nt} WHERE type IN ({content_ph})",
733
+ self._NOISE_CONTENT_TYPES,
734
+ ).fetchone()["c"]
735
+
736
+ concept_ph = ",".join("?" for _ in self._NOISE_CONCEPT_TYPES)
737
+ concept_rows = conn.execute(
738
+ f"SELECT id, type, title, metadata_json FROM {nt} WHERE type IN ({concept_ph})",
739
+ self._NOISE_CONCEPT_TYPES,
740
+ ).fetchall()
741
+ concepts = []
742
+ for row in concept_rows:
743
+ meta = _safe_loads(row["metadata_json"]) or {}
744
+ heuristic = bool(meta.get("auto_extracted")) or (
745
+ meta.get("source") == "graph_curator" or meta.get("curated") is True
746
+ )
747
+ # Document frequency: distinct *content* nodes linked to this
748
+ # concept in either direction.
749
+ df = conn.execute(
750
+ f"""
751
+ SELECT COUNT(DISTINCT n.id) AS c
752
+ FROM {et} e
753
+ JOIN {nt} n
754
+ ON n.id = CASE WHEN e.to_node = ? THEN e.from_node ELSE e.to_node END
755
+ WHERE (e.to_node = ? OR e.from_node = ?)
756
+ AND n.type IN ({content_ph})
757
+ """,
758
+ (row["id"], row["id"], row["id"], *self._NOISE_CONTENT_TYPES),
759
+ ).fetchone()["c"]
760
+ concepts.append({
761
+ "id": row["id"],
762
+ "label": row["title"],
763
+ "type": row["type"],
764
+ "df": int(df or 0),
765
+ "heuristic": heuristic,
766
+ })
767
+
768
+ plan = plan_concept_noise_reduction(
769
+ concepts,
770
+ total_docs,
771
+ max_df_ratio=max_df_ratio,
772
+ min_doc_frequency=min_doc_frequency,
773
+ min_corpus_docs=min_corpus_docs,
774
+ )
775
+ removals = plan["remove"][:max_removals]
776
+
777
+ verb_index = build_relation_verb_index()
778
+ edge_type_rows = conn.execute(
779
+ f"SELECT DISTINCT type FROM {et}"
780
+ ).fetchall()
781
+ verb_plan = (
782
+ plan_relation_normalization(
783
+ (row["type"] for row in edge_type_rows), index=verb_index,
784
+ )
785
+ if normalize_verbs
786
+ else {}
787
+ )
788
+
789
+ removed_count = 0
790
+ renamed_edges = 0
791
+ if not dry_run:
792
+ for decision in removals:
793
+ node_id = decision["id"]
794
+ conn.execute(
795
+ "DELETE FROM edges WHERE from_node=? OR to_node=?",
796
+ (node_id, node_id),
797
+ )
798
+ conn.execute(
799
+ "DELETE FROM vector_embeddings WHERE item_id=?", (node_id,)
800
+ )
801
+ conn.execute("DELETE FROM nodes WHERE id=?", (node_id,))
802
+ self._v2_delete_nodes(conn, [node_id])
803
+ removed_count += 1
804
+ for original, canonical in verb_plan.items():
805
+ renamed_edges += conn.execute(
806
+ "SELECT COUNT(*) AS c FROM edges WHERE type=?", (original,)
807
+ ).fetchone()["c"]
808
+ # UNIQUE(from_node, to_node, type): merge rows that collide
809
+ # after the rename instead of failing the UPDATE.
810
+ conn.execute(
811
+ "UPDATE OR IGNORE edges SET type=? WHERE type=?",
812
+ (canonical, original),
813
+ )
814
+ conn.execute("DELETE FROM edges WHERE type=?", (original,))
815
+ # Stamp every applied run — even a no-op one means the graph
816
+ # was inspected, so the Command Center hygiene advisory
817
+ # (review 2026-07-25 Wave 2.5) stops re-suggesting for a while.
818
+ conn.execute(
819
+ "INSERT OR REPLACE INTO graph_meta(key, value) VALUES (?, ?)",
820
+ (_LAST_NOISE_CURATE_KEY, _now()),
821
+ )
822
+
823
+ return {
824
+ "status": "ok",
825
+ "dry_run": bool(dry_run),
826
+ "total_content_docs": int(total_docs or 0),
827
+ "concepts_examined": len(concepts),
828
+ "remove": removals,
829
+ "remove_total": len(plan["remove"]),
830
+ "kept": len(plan["keep"]),
831
+ "protected_user_nodes": sum(
832
+ 1 for item in plan["keep"] if item.get("reason") == "user_created_protected"
833
+ ),
834
+ "verb_normalizations": verb_plan,
835
+ "applied": {
836
+ "removed_nodes": removed_count,
837
+ "renamed_edges": renamed_edges,
838
+ },
839
+ "thresholds": {
840
+ "max_df_ratio": float(max_df_ratio),
841
+ "min_doc_frequency": int(min_doc_frequency),
842
+ "min_corpus_docs": int(min_corpus_docs),
843
+ },
844
+ }
845
+
846
+ def last_noise_curate_at(self) -> Optional[str]:
847
+ """Timestamp of the last applied (dry_run=False) noise-curate run.
848
+
849
+ ``None`` when the job never ran or the meta table is unreadable —
850
+ advisory readers treat both as "curation is due" (fail-open).
851
+ """
852
+ try:
853
+ with self._connect() as conn:
854
+ row = conn.execute(
855
+ "SELECT value FROM graph_meta WHERE key=?",
856
+ (_LAST_NOISE_CURATE_KEY,),
857
+ ).fetchone()
858
+ except sqlite3.Error:
859
+ return None
860
+ return str(row["value"]) if row and row["value"] else None
861
+
517
862
  def mark_superseded(self, old_node_id: str, new_node_id: str) -> Dict[str, Any]:
518
863
  """Record that ``old_node_id`` was replaced by ``new_node_id``.
519
864
 
@@ -450,7 +450,7 @@ class KnowledgeGraphRetrievalMixin:
450
450
  query: str,
451
451
  *,
452
452
  top_k: int = 20,
453
- alpha: float = 0.6,
453
+ alpha: Optional[float] = None,
454
454
  workspace_id: Optional[str] = None,
455
455
  allowed_workspaces=None,
456
456
  include_legacy_global: bool = False,
@@ -476,6 +476,18 @@ class KnowledgeGraphRetrievalMixin:
476
476
 
477
477
  ``workspace_id`` is a convenience for single-workspace callers; the
478
478
  richer ``allowed_workspaces`` set wins when both are provided.
479
+
480
+ ``alpha=None`` (the default) resolves the vector share from the
481
+ single retrieval policy (:mod:`lattice_brain.graph.retrieval_policy`,
482
+ which wraps the query-class fusion table): fact 0.6 (the historical
483
+ default) / code 0.35 / person 0.45 / recency 0.5, config-overridable
484
+ via ``LATTICEAI_FUSION_WEIGHTS``. The policy also supplies a
485
+ deterministic rule-based query rewrite (echoed additively under
486
+ ``"policy"``; the response ``"query"`` stays the original) and, for
487
+ the ``recency`` class only, an age-decay half-life that dampens each
488
+ fused score into the ``[0.5, 1.0]`` band (``scores.age_decay``).
489
+ Passing an explicit ``alpha`` pins it exactly as before and disables
490
+ rewrite + decay.
479
491
  """
480
492
  query = str(query or "").strip()
481
493
  try:
@@ -483,6 +495,26 @@ class KnowledgeGraphRetrievalMixin:
483
495
  except (TypeError, ValueError):
484
496
  top_k = 20
485
497
  top_k = max(1, min(top_k, 100))
498
+ query_class: Optional[str] = None
499
+ search_query = query
500
+ rewrite_rules: List[str] = []
501
+ recency_half_life_days: Optional[float] = None
502
+ if alpha is None:
503
+ try:
504
+ from .retrieval_policy import resolve_policy
505
+
506
+ policy = resolve_policy(query)
507
+ query_class = policy["query_class"]
508
+ alpha = float(policy["alpha"])
509
+ rewrite_rules = list(policy.get("rewrite_rules") or [])
510
+ rewritten = str(policy.get("search_query") or "")
511
+ if rewritten and rewritten != query:
512
+ search_query = rewritten
513
+ half_life = policy.get("recency_half_life_days")
514
+ if half_life is not None:
515
+ recency_half_life_days = float(half_life)
516
+ except Exception: # noqa: BLE001 — policy resolution must never break search
517
+ alpha = 0.6
486
518
  try:
487
519
  alpha = float(alpha)
488
520
  except (TypeError, ValueError):
@@ -496,9 +528,11 @@ class KnowledgeGraphRetrievalMixin:
496
528
  "query": query,
497
529
  "mode": "hybrid",
498
530
  "alpha": alpha,
531
+ "query_class": query_class,
499
532
  "top_k": top_k,
500
533
  "sources": {"lexical": 0, "vector": 0},
501
534
  "matches": [],
535
+ "policy": {"search_query": search_query, "rewrite_rules": rewrite_rules},
502
536
  "detail": None,
503
537
  }
504
538
 
@@ -506,7 +540,7 @@ class KnowledgeGraphRetrievalMixin:
506
540
  vec_fetch = max(1, min(int(vector_limit or max(top_k * 2, 20)), 100))
507
541
 
508
542
  lexical_matches = self.search(
509
- query,
543
+ search_query,
510
544
  lex_fetch,
511
545
  allowed_workspaces=allowed_workspaces,
512
546
  include_legacy_global=include_legacy_global,
@@ -522,7 +556,7 @@ class KnowledgeGraphRetrievalMixin:
522
556
  else:
523
557
  try:
524
558
  vector_matches = list(
525
- (vector_fn(query, limit=vec_fetch, min_score=min_vector_score) or {}).get(
559
+ (vector_fn(search_query, limit=vec_fetch, min_score=min_vector_score) or {}).get(
526
560
  "matches", []
527
561
  )
528
562
  )
@@ -530,6 +564,17 @@ class KnowledgeGraphRetrievalMixin:
530
564
  mode = "lexical_only"
531
565
  detail = f"vector index unavailable: {exc}"
532
566
  vector_matches = []
567
+ # An embedder swap makes the vector channel silently return zero rows
568
+ # (vector_search filters on the CURRENT model/dim). Surface the honest
569
+ # cause additively without changing the mode string.
570
+ vector_degraded: Optional[str] = None
571
+ if mode == "hybrid" and not vector_matches:
572
+ try:
573
+ fingerprint_fn = getattr(self, "embedder_fingerprint_status", None)
574
+ if callable(fingerprint_fn) and fingerprint_fn().get("stale_embedder"):
575
+ vector_degraded = "stale_embedder"
576
+ except Exception: # noqa: BLE001 — fingerprint status must never break search
577
+ vector_degraded = None
533
578
  if vector_matches and allowed_workspaces is not None:
534
579
  vector_matches = self.filter_scoped_nodes(
535
580
  vector_matches,
@@ -616,19 +661,41 @@ class KnowledgeGraphRetrievalMixin:
616
661
  entry["fusion"] = "lexical"
617
662
  matches.append(entry)
618
663
 
664
+ # Recency-class age decay (retrieval_policy): dampen each fused score
665
+ # into the [0.5, 1.0] band so old-but-relevant items sink without ever
666
+ # being zeroed. Other classes skip this block byte-identically.
667
+ if recency_half_life_days is not None:
668
+ decay_now = datetime.now()
669
+ for match in matches:
670
+ stamp = match.get("updated_at")
671
+ if _parse_iso(stamp):
672
+ multiplier = 0.5 + 0.5 * _recency_score(
673
+ stamp, now=decay_now, half_life_days=recency_half_life_days
674
+ )
675
+ else:
676
+ # Unknown age is not evidence of staleness — never dampen.
677
+ multiplier = 1.0
678
+ match["scores"]["age_decay"] = round(multiplier, 6)
679
+ match["score"] = round(float(match["score"]) * multiplier, 6)
680
+
619
681
  matches.sort(key=lambda item: (-item["score"], item["node_id"]))
620
682
  matches = matches[:top_k]
621
683
  for rank, match in enumerate(matches, start=1):
622
684
  match["rank"] = rank
623
- return {
685
+ result = {
624
686
  "query": query,
625
687
  "mode": mode,
626
688
  "alpha": alpha,
689
+ "query_class": query_class,
627
690
  "top_k": top_k,
628
691
  "sources": {"lexical": len(lexical_matches), "vector": len(vector_matches)},
629
692
  "matches": matches,
693
+ "policy": {"search_query": search_query, "rewrite_rules": rewrite_rules},
630
694
  "detail": detail,
631
695
  }
696
+ if vector_degraded is not None:
697
+ result["vector_degraded"] = vector_degraded
698
+ return result
632
699
 
633
700
  def context_for_query(
634
701
  self,