cortexm 0.5.1__tar.gz → 0.5.2__tar.gz

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 (167) hide show
  1. {cortexm-0.5.1/cortexm.egg-info → cortexm-0.5.2}/PKG-INFO +109 -46
  2. {cortexm-0.5.1 → cortexm-0.5.2}/README.md +108 -45
  3. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/__init__.py +1 -1
  4. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/api/long_recall.py +0 -0
  5. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bridge/fusion.py +0 -0
  6. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bridge/reader.py +118 -0
  7. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/creator.py +0 -0
  8. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/kernel.py +0 -0
  9. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/markdown_io.py +0 -0
  10. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/pipeline.py +0 -0
  11. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/plugins/__init__.py +0 -0
  12. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/plugins/security.py +0 -0
  13. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/plugins/structured.py +0 -0
  14. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/plugins/verbatim.py +0 -0
  15. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/router.py +0 -0
  16. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/security/permission.py +16 -1
  17. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/trajectory_view.py +0 -0
  18. {cortexm-0.5.1 → cortexm-0.5.2/cortexm.egg-info}/PKG-INFO +109 -46
  19. {cortexm-0.5.1 → cortexm-0.5.2}/pyproject.toml +1 -1
  20. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_bench_infra.py +0 -0
  21. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_bm25_chunk_recall_and_inspect_cli.py +0 -0
  22. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_fusion_security.py +0 -0
  23. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_kernel.py +0 -0
  24. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_permission.py +165 -0
  25. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_reddit_steals_round3.py +0 -0
  26. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_tier443_abstention_fix.py +0 -0
  27. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_verbatim.py +0 -0
  28. {cortexm-0.5.1 → cortexm-0.5.2}/LICENSE +0 -0
  29. {cortexm-0.5.1 → cortexm-0.5.2}/context_m.py +0 -0
  30. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/accel.py +0 -0
  31. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/api/__init__.py +0 -0
  32. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/api/chaos.py +0 -0
  33. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/api/memory.py +0 -0
  34. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bench/__init__.py +0 -0
  35. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bench/abilities.py +0 -0
  36. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bench/baselines.py +0 -0
  37. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bench/beam_loader.py +0 -0
  38. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bench/generator.py +0 -0
  39. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bench/harness.py +0 -0
  40. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bench/messy.py +0 -0
  41. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bench/micro.py +0 -0
  42. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bench/ood.py +0 -0
  43. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bench/run.py +0 -0
  44. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bridge/__init__.py +0 -0
  45. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bridge/dates.py +0 -0
  46. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bridge/decoders.py +0 -0
  47. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bridge/enrich.py +0 -0
  48. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bridge/extractor.py +0 -0
  49. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bridge/fallback.py +0 -0
  50. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bridge/onnx_runtime.py +0 -0
  51. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bridge/patterns.py +0 -0
  52. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bridge/ppr.py +0 -0
  53. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bridge/prefilter.py +0 -0
  54. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bridge/query_extract.py +0 -0
  55. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bridge/rerank.py +0 -0
  56. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/bridge/writer.py +0 -0
  57. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/cli.py +0 -0
  58. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/cognition/__init__.py +0 -0
  59. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/cognition/abstraction.py +0 -0
  60. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/cognition/analogy.py +0 -0
  61. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/cognition/engine.py +0 -0
  62. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/cognition/gaps.py +0 -0
  63. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/cognition/scanner.py +0 -0
  64. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/config.py +0 -0
  65. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/cortexm.py +0 -0
  66. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/enterprise/__init__.py +0 -0
  67. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/enterprise/audit.py +0 -0
  68. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/enterprise/governance.py +0 -0
  69. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/errors.py +0 -0
  70. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/features/__init__.py +0 -0
  71. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/features/git.py +0 -0
  72. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/features/prefetch.py +0 -0
  73. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/features/zk.py +0 -0
  74. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/federation/__init__.py +0 -0
  75. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/federation/crdt.py +0 -0
  76. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/federation/fabric.py +0 -0
  77. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/federation/hlc.py +0 -0
  78. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/federation/node.py +0 -0
  79. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/federation/schema_report.py +0 -0
  80. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/federation/transport.py +0 -0
  81. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/index/__init__.py +0 -0
  82. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/index/nsg.py +0 -0
  83. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/mcp/__init__.py +0 -0
  84. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/mcp/server.py +0 -0
  85. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/metrics.py +0 -0
  86. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/migrate/__init__.py +0 -0
  87. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/migrate/importers.py +0 -0
  88. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/provenance/__init__.py +0 -0
  89. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/provenance/agent.py +0 -0
  90. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/provenance/cose.py +0 -0
  91. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/provenance/scitt.py +0 -0
  92. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/provenance/vc.py +0 -0
  93. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/security/__init__.py +0 -0
  94. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/security/crypto.py +0 -0
  95. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/security/hashes.py +0 -0
  96. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/security/injection.py +0 -0
  97. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/security/mind.py +0 -0
  98. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/security/pii.py +0 -0
  99. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/security/rbac.py +0 -0
  100. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/security/sandbox.py +0 -0
  101. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/security/zk_hamming.py +0 -0
  102. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/security/zk_sql.py +0 -0
  103. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/server/__init__.py +0 -0
  104. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/server/metrics.py +0 -0
  105. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/server/rest.py +0 -0
  106. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/server/sparql.py +0 -0
  107. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/text/__init__.py +0 -0
  108. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/text/dissim.py +0 -0
  109. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/text/embedder.py +0 -0
  110. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/text/fuzzy.py +0 -0
  111. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/text/idiolect.py +0 -0
  112. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/text/labse.py +0 -0
  113. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/text/tokenizer.py +0 -0
  114. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/trace/__init__.py +0 -0
  115. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/trace/blob_arena.py +0 -0
  116. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/trace/consolidate.py +0 -0
  117. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/trace/contradictions.py +0 -0
  118. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/trace/dedup.py +0 -0
  119. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/trace/edges.py +0 -0
  120. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/trace/fact.py +0 -0
  121. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/trace/fade.py +0 -0
  122. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/trace/lifecycle.py +0 -0
  123. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/trace/rebuild.py +0 -0
  124. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/trace/rules.py +0 -0
  125. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/trace/store.py +0 -0
  126. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/trace/structural.py +0 -0
  127. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/trace/tmt.py +0 -0
  128. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/util.py +0 -0
  129. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/vsa/__init__.py +0 -0
  130. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/vsa/attribution.py +0 -0
  131. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/vsa/cleanup.py +0 -0
  132. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/vsa/codecs.py +0 -0
  133. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/vsa/hologram_overlay.py +0 -0
  134. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/vsa/index.py +0 -0
  135. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/vsa/ops.py +0 -0
  136. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/vsa/palace.py +0 -0
  137. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/vsa/role_vectors.py +0 -0
  138. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/vsa/slb.py +0 -0
  139. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/vsa/tlsh_trie.py +0 -0
  140. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm/vsa/working_memory.py +0 -0
  141. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm.egg-info/SOURCES.txt +0 -0
  142. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm.egg-info/dependency_links.txt +0 -0
  143. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm.egg-info/entry_points.txt +0 -0
  144. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm.egg-info/requires.txt +0 -0
  145. {cortexm-0.5.1 → cortexm-0.5.2}/cortexm.egg-info/top_level.txt +0 -0
  146. {cortexm-0.5.1 → cortexm-0.5.2}/setup.cfg +0 -0
  147. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_arxiv_improvements.py +0 -0
  148. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_cognition_and_provenance.py +0 -0
  149. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_engineering_push_2026_08_28.py +0 -0
  150. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_enterprise.py +0 -0
  151. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_fabric.py +0 -0
  152. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_federation.py +0 -0
  153. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_kinship_extraction.py +0 -0
  154. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_labse.py +0 -0
  155. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_list_superseded_intent.py +0 -0
  156. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_migration.py +0 -0
  157. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_new_modules.py +0 -0
  158. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_nsg.py +0 -0
  159. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_ppr.py +0 -0
  160. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_rerank.py +0 -0
  161. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_research_steals.py +0 -0
  162. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_research_steals_round2.py +0 -0
  163. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_rust_accel.py +0 -0
  164. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_sandbox_enrich.py +0 -0
  165. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_sparql_rest_v2.py +0 -0
  166. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_wal_recovery.py +0 -0
  167. {cortexm-0.5.1 → cortexm-0.5.2}/tests/test_zk_sql.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortexm
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: Deterministic agent memory. 96 bytes per fact. Zero LLM at ingest.
5
5
  Author: Context-M Contributors
6
6
  License: Apache-2.0
@@ -100,39 +100,86 @@ handling accented characters without crashing the trigger.
100
100
 
101
101
  ### Tier 4.3 — LongMemEval independent judge
102
102
 
103
- | subtask | pre-fix | post-fix (2026-08-28) | plugin-kernel (v0.5.0) | **v0.5.1** | Δ vs pre-fix |
103
+ | subtask | pre-fix | post-fix (2026-08-28) | plugin-kernel (v0.5.0) | v0.5.1 synthetic | **v0.5.2 canonical** |
104
104
  |---|---|---|---|---|---|
105
- | single_hop | 1.0 | 1.0 | 1.0 | **1.0** | flat |
106
- | knowledge_update | 0.333 | 0.667 | 1.000 | **1.0** | 3× |
107
- | multi_session | 0.5 | 0.5 | 0.5 | **1.0** | 2× |
108
- | temporal_reasoning | 0.5 | 0.5 | 0.5 | **1.0** | 2× |
109
- | **overall** | 0.600 | 0.700 | 0.800 | **1.000** | +40pp |
110
-
111
- **v0.5.1: MemPalace parity achieved.** MemPalace (246K-step benchmark)
112
- got 96.6% recall at $0 cost; Context-M now hits **1.000** on a 20-question
113
- LongMemEval synthetic subset, μ=0 (no LLM at ingest or retrieval, no
114
- API call). The two key fixes:
115
-
116
- 1. **Smarter deterministic judge.** Three rule-based strategies
117
- replace the old literal-substring match: **NUGGET** for
118
- single-entity answers, **LIST** for "X and Y" answers (checks
119
- every part appears in the context, not the literal "X and Y"
120
- string), **BOOL** for yes/no answers (queries the bi-temporal
121
- Trace for distinct values of the (entity, attribute) pair —
122
- ≥2 ⇒ Yes). All pure Python + SQL. No LLM.
123
-
124
- 2. **Wider retrieval window.** `limit=10` instead of 5. Earlier
125
- queries boost `access_count` on frequently-retrieved facts
126
- (Bob|name, Bob|works_at|OpenAI), which can push rarer
127
- multi-session facts (speaks|English, has_skill|Kubernetes) out
128
- of top-5. A wider window keeps both values of a list answer
129
- surfaced.
130
-
131
- Reproduce: `python scripts/longmemeval_judge.py --out
132
- benchmarks/results/longmemeval_v0.5.1.json` ·
133
- [`benchmarks/results/longmemeval_v0.5.1.json`](benchmarks/results/longmemeval_v0.5.1.json).
134
- Determinism: 3 sequential runs return `det_judge_accuracy: 1.0` on
135
- every run — the "same every time" promise holds.
105
+ | single_hop | 1.0 | 1.0 | 1.0 | 1.0 | **0.222** |
106
+ | knowledge_update | 0.333 | 0.667 | 1.000 | 1.0 | **0.333** |
107
+ | multi_session | 0.5 | 0.5 | 0.5 | 1.0 | **0.333** |
108
+ | temporal_reasoning | 0.5 | 0.5 | 0.5 | 1.0 | **0.667** |
109
+ | **overall** | 0.600 | 0.700 | 0.800 | 1.000 | **0.333** |
110
+
111
+ **v0.5.2: the honest canonical score.** MemPalace (246K-step benchmark)
112
+ got 96.6% recall at $0 cost. Context-M hits **1.000** on a 20-question
113
+ synthetic LongMemEval subset (matches MemPalace's framing on data we
114
+ control), and **0.333** on a real 18-question sample from the
115
+ canonical `xiaowu0162/longmemeval-cleaned` benchmark — μ=0
116
+ throughout (no LLM at ingest, retrieval, or judging).
117
+
118
+ We do NOT claim parity on the canonical 500-question, 23,867-session
119
+ benchmark. We claim:
120
+
121
+ 1. **End-to-end deterministic QA is possible.** MemPalace stops at
122
+ retrieval — they never answer the question. We built the full
123
+ pipeline: Question → Intent Router → Datalog-lite / Trace / VSA →
124
+ Answer Extraction → Judge → Score. All zero neural networks.
125
+
126
+ 2. **The 1.000 on synthetic is real.** Same judge, same reader,
127
+ same Trace, run 3× — every run returns 1.0. Promise #5 holds.
128
+
129
+ 3. **The 0.333 on canonical is also real.** Real human text —
130
+ slang, typos, indirect speech, code-mixed language, multi-game
131
+ arithmetic, meta-answer preference questions — the deterministic
132
+ extractor misses things an LLM would catch. That's the honest
133
+ gap, by design (μ=0 trades breadth for cost).
134
+
135
+ **v0.5.2 wiring fixes** (the user-identified gap on
136
+ multi_session + temporal_reasoning):
137
+
138
+ 1. **`recall_step` wired into the LongMemEval reader path.** The
139
+ asymmetric step-distance boost surfaces scrolled-out session-1
140
+ facts that the access_count boost on current session-N facts
141
+ would otherwise push below top-k. This is the multi_session fix:
142
+ older-session facts that list questions need now have a higher
143
+ retrieval weight.
144
+
145
+ 2. **Temporal query pre-processor + TEMPORAL CHAIN note.** When the
146
+ question matches `when/before/after/did X move/did X change/how
147
+ many times`, the reader walks the bi-temporal SUPERSEDES chain
148
+ per (entity, relation) and emits an explicit ordering note:
149
+ `TEMPORAL CHAIN: Bob|lives_in: Berlin (SUPERSEDED) → Munich
150
+ (CURRENT) → 1 supersession(s) detected → Bob changed`. The BOOL
151
+ judge reads this directly (STRATEGY 0), bypassing the regex
152
+ fallback. canonical temporal_reasoning went 0.5 → 0.667.
153
+
154
+ 3. **Smarter NUGGET + LIST judges.** Both now fall back to token-
155
+ overlap when literal-substring fails, so canonical answers like
156
+ "4 years and 9 months" score True when both tokens appear in
157
+ the context, even if the exact "and"-joined phrase doesn't.
158
+
159
+ **Reproduce (synthetic, 1.000):**
160
+ ```
161
+ python scripts/longmemeval_judge.py \
162
+ --out benchmarks/results/longmemeval_v0.5.2_synth.json
163
+ ```
164
+
165
+ **Reproduce (canonical, 0.333):**
166
+ ```
167
+ # one-time: download the canonical benchmark (~277 MB)
168
+ python -c "from huggingface_hub import hf_hub_download; \
169
+ hf_hub_download(repo_id='xiaowu0162/longmemeval-cleaned', \
170
+ repo_type='dataset', filename='longmemeval_s_cleaned.json', \
171
+ local_dir='data/longmemeval')"
172
+
173
+ # sample 3 questions per subtask (18 total), μ=0 ingest + judge
174
+ python scripts/longmemeval_canonical.py \
175
+ --n-per-type 3 --max-messages-per-q 300 \
176
+ --out benchmarks/results/canonical_longmemeval_v0.5.2_n3.json
177
+ ```
178
+
179
+ Determinism: 3 sequential synthetic runs return
180
+ `det_judge_accuracy: 1.0` every run — the "same every time" promise
181
+ holds. The canonical score varies with the random sample seed; the
182
+ overall 0.333 is reproducible with `--seed 42`.
136
183
 
137
184
  Pre-plugin-kernel fixes (0.600 → 0.700): (1) `works_at` regex
138
185
  contraction fix ("I'm now working at OpenAI" now extracts),
@@ -153,11 +200,20 @@ questions get both values surfaced. The 2 remaining answer-shape
153
200
  mismatches the previous run hit are now closed — every question has
154
201
  a strategy that can answer it correctly without an LLM.
155
202
 
203
+ v0.5.2 fixes (canonical 0.333 honest scope + temporal_reasoning
204
+ 0.667): `recall_step` wired into the reader path (surfaces scrolled-
205
+ out facts); TEMPORAL CHAIN note emitted for `when/before/after/did
206
+ X move` questions (BOOL judge reads the verdict directly); NUGGET +
207
+ LIST judges gain token-overlap fallbacks for free-form canonical
208
+ answers.
209
+
156
210
  That is the capability profile of the μ=0 extractor on real phrasing:
157
211
  strong on change-of-state statements, weak on identity/preference
158
- restatements, weak on non-English without the LaBSE polyglot encoder.
159
- The async LLM enrichment fallback helps marginally — it surfaces facts
160
- but does not reconstruct bi-temporal chains. [`docs/FAILURE_MODES.md`](docs/FAILURE_MODES.md)
212
+ restatements, weak on non-English without the LaBSE polyglot encoder,
213
+ weak on arithmetic ("how many hours total" requires summing across
214
+ chunks — deterministic reader can't add). The async LLM enrichment
215
+ fallback helps marginally — it surfaces facts but does not reconstruct
216
+ bi-temporal chains. [`docs/FAILURE_MODES.md`](docs/FAILURE_MODES.md)
161
217
  documents which phrasings break, with worked examples.
162
218
 
163
219
  **Independent LLM judges grade these numbers *lower*, not higher.** The
@@ -320,16 +376,23 @@ genuine pre-existing read-path leaks (empty-scope fallback, falsy scope
320
376
  checks, unscoped supersession chains). `verify_integrity()` audits the
321
377
  whole store.
322
378
 
323
- The **PermissionGate** (new v0.5.1) is a default-deny gate for code
324
- execution + user-data reads. Plugins that want to invoke `os.system`
325
- / `subprocess` / `open()` on the user's behalf MUST first call
326
- `permission.grant_read(path)` or `permission.grant_exec(cmd)` —
327
- otherwise the gate denies and audits the attempt. Sensitive paths
328
- (`~/.ssh`, `~/.aws`, `/etc/passwd`, `~/.config/gh`) and sensitive
329
- executables (`curl`, `wget`, `sudo`, `ssh`, `nc`) are ALWAYS denied
330
- unless the user calls `grant_sensitive()` on the exact item. There is
331
- no wildcard. Composition, not coercion: the plugin doesn't monkeypatch
332
- `os` or `subprocess` — plugins that consult the gate are gated; plugins
379
+ The **PermissionGate** (v0.5.1; **hardened v0.5.2**) is a default-deny
380
+ gate for code execution + user-data reads. The user directive:
381
+
382
+ > "security is important — no malicious code shall be executed to read
383
+ > user data without explicit permission."
384
+
385
+ is enforced as a strict allowlist with NO wildcards:
386
+
387
+ Plugins that want to invoke `os.system` / `subprocess` / `open()` on
388
+ the user's behalf MUST first call `permission.grant_read(path)` or
389
+ `permission.grant_exec(cmd)` — otherwise the gate denies and audits
390
+ the attempt. Sensitive paths (`~/.ssh`, `~/.aws`, `/etc/passwd`,
391
+ `~/.config/gh`) and sensitive executables (`curl`, `wget`, `sudo`,
392
+ `ssh`, `nc`) are ALWAYS denied unless the user calls
393
+ `grant_sensitive()` on the exact item. There is no wildcard.
394
+ Composition, not coercion: the plugin doesn't monkeypatch `os` or
395
+ `subprocess` — plugins that consult the gate are gated; plugins
333
396
  that ignore it are not. [`tests/test_permission.py`](tests/test_permission.py),
334
397
  34 tests.
335
398
 
@@ -80,39 +80,86 @@ handling accented characters without crashing the trigger.
80
80
 
81
81
  ### Tier 4.3 — LongMemEval independent judge
82
82
 
83
- | subtask | pre-fix | post-fix (2026-08-28) | plugin-kernel (v0.5.0) | **v0.5.1** | Δ vs pre-fix |
83
+ | subtask | pre-fix | post-fix (2026-08-28) | plugin-kernel (v0.5.0) | v0.5.1 synthetic | **v0.5.2 canonical** |
84
84
  |---|---|---|---|---|---|
85
- | single_hop | 1.0 | 1.0 | 1.0 | **1.0** | flat |
86
- | knowledge_update | 0.333 | 0.667 | 1.000 | **1.0** | 3× |
87
- | multi_session | 0.5 | 0.5 | 0.5 | **1.0** | 2× |
88
- | temporal_reasoning | 0.5 | 0.5 | 0.5 | **1.0** | 2× |
89
- | **overall** | 0.600 | 0.700 | 0.800 | **1.000** | +40pp |
90
-
91
- **v0.5.1: MemPalace parity achieved.** MemPalace (246K-step benchmark)
92
- got 96.6% recall at $0 cost; Context-M now hits **1.000** on a 20-question
93
- LongMemEval synthetic subset, μ=0 (no LLM at ingest or retrieval, no
94
- API call). The two key fixes:
95
-
96
- 1. **Smarter deterministic judge.** Three rule-based strategies
97
- replace the old literal-substring match: **NUGGET** for
98
- single-entity answers, **LIST** for "X and Y" answers (checks
99
- every part appears in the context, not the literal "X and Y"
100
- string), **BOOL** for yes/no answers (queries the bi-temporal
101
- Trace for distinct values of the (entity, attribute) pair —
102
- ≥2 ⇒ Yes). All pure Python + SQL. No LLM.
103
-
104
- 2. **Wider retrieval window.** `limit=10` instead of 5. Earlier
105
- queries boost `access_count` on frequently-retrieved facts
106
- (Bob|name, Bob|works_at|OpenAI), which can push rarer
107
- multi-session facts (speaks|English, has_skill|Kubernetes) out
108
- of top-5. A wider window keeps both values of a list answer
109
- surfaced.
110
-
111
- Reproduce: `python scripts/longmemeval_judge.py --out
112
- benchmarks/results/longmemeval_v0.5.1.json` ·
113
- [`benchmarks/results/longmemeval_v0.5.1.json`](benchmarks/results/longmemeval_v0.5.1.json).
114
- Determinism: 3 sequential runs return `det_judge_accuracy: 1.0` on
115
- every run — the "same every time" promise holds.
85
+ | single_hop | 1.0 | 1.0 | 1.0 | 1.0 | **0.222** |
86
+ | knowledge_update | 0.333 | 0.667 | 1.000 | 1.0 | **0.333** |
87
+ | multi_session | 0.5 | 0.5 | 0.5 | 1.0 | **0.333** |
88
+ | temporal_reasoning | 0.5 | 0.5 | 0.5 | 1.0 | **0.667** |
89
+ | **overall** | 0.600 | 0.700 | 0.800 | 1.000 | **0.333** |
90
+
91
+ **v0.5.2: the honest canonical score.** MemPalace (246K-step benchmark)
92
+ got 96.6% recall at $0 cost. Context-M hits **1.000** on a 20-question
93
+ synthetic LongMemEval subset (matches MemPalace's framing on data we
94
+ control), and **0.333** on a real 18-question sample from the
95
+ canonical `xiaowu0162/longmemeval-cleaned` benchmark — μ=0
96
+ throughout (no LLM at ingest, retrieval, or judging).
97
+
98
+ We do NOT claim parity on the canonical 500-question, 23,867-session
99
+ benchmark. We claim:
100
+
101
+ 1. **End-to-end deterministic QA is possible.** MemPalace stops at
102
+ retrieval — they never answer the question. We built the full
103
+ pipeline: Question → Intent Router → Datalog-lite / Trace / VSA →
104
+ Answer Extraction → Judge → Score. All zero neural networks.
105
+
106
+ 2. **The 1.000 on synthetic is real.** Same judge, same reader,
107
+ same Trace, run 3× — every run returns 1.0. Promise #5 holds.
108
+
109
+ 3. **The 0.333 on canonical is also real.** Real human text —
110
+ slang, typos, indirect speech, code-mixed language, multi-game
111
+ arithmetic, meta-answer preference questions — the deterministic
112
+ extractor misses things an LLM would catch. That's the honest
113
+ gap, by design (μ=0 trades breadth for cost).
114
+
115
+ **v0.5.2 wiring fixes** (the user-identified gap on
116
+ multi_session + temporal_reasoning):
117
+
118
+ 1. **`recall_step` wired into the LongMemEval reader path.** The
119
+ asymmetric step-distance boost surfaces scrolled-out session-1
120
+ facts that the access_count boost on current session-N facts
121
+ would otherwise push below top-k. This is the multi_session fix:
122
+ older-session facts that list questions need now have a higher
123
+ retrieval weight.
124
+
125
+ 2. **Temporal query pre-processor + TEMPORAL CHAIN note.** When the
126
+ question matches `when/before/after/did X move/did X change/how
127
+ many times`, the reader walks the bi-temporal SUPERSEDES chain
128
+ per (entity, relation) and emits an explicit ordering note:
129
+ `TEMPORAL CHAIN: Bob|lives_in: Berlin (SUPERSEDED) → Munich
130
+ (CURRENT) → 1 supersession(s) detected → Bob changed`. The BOOL
131
+ judge reads this directly (STRATEGY 0), bypassing the regex
132
+ fallback. canonical temporal_reasoning went 0.5 → 0.667.
133
+
134
+ 3. **Smarter NUGGET + LIST judges.** Both now fall back to token-
135
+ overlap when literal-substring fails, so canonical answers like
136
+ "4 years and 9 months" score True when both tokens appear in
137
+ the context, even if the exact "and"-joined phrase doesn't.
138
+
139
+ **Reproduce (synthetic, 1.000):**
140
+ ```
141
+ python scripts/longmemeval_judge.py \
142
+ --out benchmarks/results/longmemeval_v0.5.2_synth.json
143
+ ```
144
+
145
+ **Reproduce (canonical, 0.333):**
146
+ ```
147
+ # one-time: download the canonical benchmark (~277 MB)
148
+ python -c "from huggingface_hub import hf_hub_download; \
149
+ hf_hub_download(repo_id='xiaowu0162/longmemeval-cleaned', \
150
+ repo_type='dataset', filename='longmemeval_s_cleaned.json', \
151
+ local_dir='data/longmemeval')"
152
+
153
+ # sample 3 questions per subtask (18 total), μ=0 ingest + judge
154
+ python scripts/longmemeval_canonical.py \
155
+ --n-per-type 3 --max-messages-per-q 300 \
156
+ --out benchmarks/results/canonical_longmemeval_v0.5.2_n3.json
157
+ ```
158
+
159
+ Determinism: 3 sequential synthetic runs return
160
+ `det_judge_accuracy: 1.0` every run — the "same every time" promise
161
+ holds. The canonical score varies with the random sample seed; the
162
+ overall 0.333 is reproducible with `--seed 42`.
116
163
 
117
164
  Pre-plugin-kernel fixes (0.600 → 0.700): (1) `works_at` regex
118
165
  contraction fix ("I'm now working at OpenAI" now extracts),
@@ -133,11 +180,20 @@ questions get both values surfaced. The 2 remaining answer-shape
133
180
  mismatches the previous run hit are now closed — every question has
134
181
  a strategy that can answer it correctly without an LLM.
135
182
 
183
+ v0.5.2 fixes (canonical 0.333 honest scope + temporal_reasoning
184
+ 0.667): `recall_step` wired into the reader path (surfaces scrolled-
185
+ out facts); TEMPORAL CHAIN note emitted for `when/before/after/did
186
+ X move` questions (BOOL judge reads the verdict directly); NUGGET +
187
+ LIST judges gain token-overlap fallbacks for free-form canonical
188
+ answers.
189
+
136
190
  That is the capability profile of the μ=0 extractor on real phrasing:
137
191
  strong on change-of-state statements, weak on identity/preference
138
- restatements, weak on non-English without the LaBSE polyglot encoder.
139
- The async LLM enrichment fallback helps marginally — it surfaces facts
140
- but does not reconstruct bi-temporal chains. [`docs/FAILURE_MODES.md`](docs/FAILURE_MODES.md)
192
+ restatements, weak on non-English without the LaBSE polyglot encoder,
193
+ weak on arithmetic ("how many hours total" requires summing across
194
+ chunks — deterministic reader can't add). The async LLM enrichment
195
+ fallback helps marginally — it surfaces facts but does not reconstruct
196
+ bi-temporal chains. [`docs/FAILURE_MODES.md`](docs/FAILURE_MODES.md)
141
197
  documents which phrasings break, with worked examples.
142
198
 
143
199
  **Independent LLM judges grade these numbers *lower*, not higher.** The
@@ -300,16 +356,23 @@ genuine pre-existing read-path leaks (empty-scope fallback, falsy scope
300
356
  checks, unscoped supersession chains). `verify_integrity()` audits the
301
357
  whole store.
302
358
 
303
- The **PermissionGate** (new v0.5.1) is a default-deny gate for code
304
- execution + user-data reads. Plugins that want to invoke `os.system`
305
- / `subprocess` / `open()` on the user's behalf MUST first call
306
- `permission.grant_read(path)` or `permission.grant_exec(cmd)` —
307
- otherwise the gate denies and audits the attempt. Sensitive paths
308
- (`~/.ssh`, `~/.aws`, `/etc/passwd`, `~/.config/gh`) and sensitive
309
- executables (`curl`, `wget`, `sudo`, `ssh`, `nc`) are ALWAYS denied
310
- unless the user calls `grant_sensitive()` on the exact item. There is
311
- no wildcard. Composition, not coercion: the plugin doesn't monkeypatch
312
- `os` or `subprocess` — plugins that consult the gate are gated; plugins
359
+ The **PermissionGate** (v0.5.1; **hardened v0.5.2**) is a default-deny
360
+ gate for code execution + user-data reads. The user directive:
361
+
362
+ > "security is important — no malicious code shall be executed to read
363
+ > user data without explicit permission."
364
+
365
+ is enforced as a strict allowlist with NO wildcards:
366
+
367
+ Plugins that want to invoke `os.system` / `subprocess` / `open()` on
368
+ the user's behalf MUST first call `permission.grant_read(path)` or
369
+ `permission.grant_exec(cmd)` — otherwise the gate denies and audits
370
+ the attempt. Sensitive paths (`~/.ssh`, `~/.aws`, `/etc/passwd`,
371
+ `~/.config/gh`) and sensitive executables (`curl`, `wget`, `sudo`,
372
+ `ssh`, `nc`) are ALWAYS denied unless the user calls
373
+ `grant_sensitive()` on the exact item. There is no wildcard.
374
+ Composition, not coercion: the plugin doesn't monkeypatch `os` or
375
+ `subprocess` — plugins that consult the gate are gated; plugins
313
376
  that ignore it are not. [`tests/test_permission.py`](tests/test_permission.py),
314
377
  34 tests.
315
378
 
@@ -18,7 +18,7 @@ Plugin kernel: ``from cortexm import Context, mount_default``
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
- __version__ = "0.5.1"
21
+ __version__ = "0.5.2"
22
22
 
23
23
  # μ=0 protocol counter: number of LLM invocations used by this process.
24
24
  # The BEAM-honest protocol requires this to stay 0 during ingest & retrieval.
@@ -180,6 +180,29 @@ SINGLE_VALUED_QUERY = re.compile(
180
180
  r"company|boss|manager|location|address|city|home)\b"
181
181
  r"|\bwhat\s+does\s+\w+\s+do\b"
182
182
  r"|\bwho\s+is\s+\w+(?:'?s)?\s+(?:manager|boss|lead|supervisor)\b)", re.I)
183
+
184
+ # Temporal chain triggers — questions whose answer requires walking the
185
+ # bi-temporal SUPERSEDES chain rather than just looking up a single fact.
186
+ # Routes "when/before/after/did X move/did X change/how many times" to
187
+ # the temporal-chain note emitter (``_temporal_chain_notes`` below),
188
+ # which surfaces an explicit "V1 → SUPERSEDED BY V2" trace per
189
+ # (entity, relation). This is the LongMemEval temporal_reasoning
190
+ # fix: the reader IS already pulling the supersession chain into the
191
+ # candidate pool (line ~1230), but the candidate facts alone don't
192
+ # tell the judge WHICH value came first / which replaced which. The
193
+ # TEMPORAL CHAIN note makes the ordering visible.
194
+ TEMPORAL_CHAIN_MARKERS = re.compile(
195
+ r"\b(when\s+(?:did|were|was|will)\b"
196
+ r"|\b(?:before|after|prior\s+to)\b"
197
+ r"|\b(?:during|while|since|until)\b"
198
+ r"|\bdid\s+\w+\s+(?:move|change|switch|leave|join|start|stop)\b"
199
+ r"|\bhas\s+\w+\s+(?:moved|changed|switched|been)\b"
200
+ r"|\bhow\s+many\s+times\b"
201
+ r"|\bprevious\w*\b|\bformer\w*\b"
202
+ r"|\bused\s+to\b|\bno\s+longer\b"
203
+ r"|\bfirst\s+(?:job|city|role|company)\b"
204
+ r"|\blast\s+(?:job|city|role|company)\b)", re.I)
205
+
183
206
  # Temporal + LIST fusion: "list all X from 2024" or "what did X do
184
207
  # between A and B" should be a temporal-list (return the full matching
185
208
  # set within the window, not just top-k). Detected downstream by the
@@ -198,6 +221,11 @@ class QueryPlan:
198
221
  # so the reader's filter knows to apply BOTH the temporal window
199
222
  # AND the exhaustive recall semantics (don't truncate to top-k).
200
223
  sub_intent: str | None = None
224
+ # v0.5.2: temporal chain flag — set when TEMPORAL_CHAIN_MARKERS
225
+ # matches. Reader emits explicit SUPERSEDES-chain notes so the
226
+ # judge can answer "did X move?" / "where before?" / "how many
227
+ # times" without guessing from candidate fact order.
228
+ wants_temporal_chain: bool = False
201
229
 
202
230
 
203
231
  @dataclass
@@ -655,6 +683,17 @@ class MemoryReader:
655
683
  plan.intent = "temporal"
656
684
  if MULTIHOP_MARKERS.search(query) and len(plan.relations) >= 2:
657
685
  plan.intent = "multihop" if plan.intent == "recall" else plan.intent
686
+ # v0.5.2: temporal chain trigger — fire on "when/before/after/
687
+ # did X move/did X change" so the reader emits an explicit
688
+ # SUPERSEDES-chain note (``_temporal_chain_notes`` below).
689
+ # This is the LongMemEval temporal_reasoning fix: the reader
690
+ # already pulls the superseded chain into the candidate pool,
691
+ # but the judge needs an explicit ordering signal — the bare
692
+ # candidate facts don't say "V1 came before V2; V2 replaced V1".
693
+ if TEMPORAL_CHAIN_MARKERS.search(query):
694
+ plan.wants_temporal_chain = True
695
+ if plan.intent == "recall":
696
+ plan.intent = "temporal"
658
697
  return plan
659
698
 
660
699
  def _employment_window(self, query: str, user_id: str) -> tuple[str, str] | None:
@@ -749,6 +788,17 @@ class MemoryReader:
749
788
  # --- symbolic path -------------------------------------------------
750
789
  sym_facts, notes = self._symbolic_query(plan, user_id, agent_id, run_id,
751
790
  scope, k, query)
791
+ # v0.5.2: temporal chain notes — for any query that triggered
792
+ # TEMPORAL_CHAIN_MARKERS, walk the bi-temporal SUPERSEDES chain
793
+ # per (entity, relation) and emit an explicit ordering note.
794
+ # The reader already pulled the supersession chain into the
795
+ # candidate pool above (in _symbolic_query); this just makes
796
+ # the *order* explicit so the LIST/BOOL judge can answer
797
+ # "did X move?" / "where before?" without guessing.
798
+ if plan.wants_temporal_chain:
799
+ tc_notes = self._temporal_chain_notes(plan, user_id)
800
+ if tc_notes:
801
+ notes = (notes or []) + tc_notes
752
802
 
753
803
  # --- query-aware triple pre-filter (HippoRAG 2 lineage) ------------
754
804
  # Drop candidate facts that have low lexical+semantic+relation
@@ -1183,6 +1233,74 @@ class MemoryReader:
1183
1233
  return ("RECONSTRUCT narrative (μ=0, rule-based):\n"
1184
1234
  + "\n".join(clauses))
1185
1235
 
1236
+ # ----------------------------------------------------- temporal chain
1237
+ def _temporal_chain_notes(self, plan: "QueryPlan", user_id: str) -> list[str]:
1238
+ """Walk the bi-temporal SUPERSEDES chain for each (entity, relation)
1239
+ in the plan and emit explicit ordering notes.
1240
+
1241
+ This is the LongMemEval temporal_reasoning fix. The reader
1242
+ already pulls superseded facts into the candidate pool (line
1243
+ ~1230 in ``_symbolic_query``), but candidate facts alone don't
1244
+ tell the judge the *order* in which values were superseded.
1245
+ For BOOL questions like "Did Bob move?" or "Did Alice change
1246
+ jobs?", the LIST/BOOL judge needs to see ≥2 distinct values
1247
+ AND know which came first. The TEMPORAL CHAIN note makes both
1248
+ visible.
1249
+
1250
+ Output format (one note per (entity, relation) with ≥2 facts)::
1251
+
1252
+ TEMPORAL CHAIN: Bob|lives_in:
1253
+ - Berlin [valid 2026-01-15 → 2026-06-12] (SUPERSEDED)
1254
+ - Munich [valid 2026-06-12 → ∞] (CURRENT)
1255
+ → 1 supersession(s) detected → Bob moved
1256
+
1257
+ μ=0: pure SQL via ``store.history_of`` (returns ordered list
1258
+ with valid_from/valid_to). No LLM.
1259
+ """
1260
+ if not plan.wants_temporal_chain:
1261
+ return []
1262
+ if not plan.entities or not plan.relations:
1263
+ # No entities / relations parsed from the query — we can't
1264
+ # walk a chain we don't know the subject of. Fall back to
1265
+ # history_of for the first user-scoped fact's subject, if any.
1266
+ return []
1267
+ notes: list[str] = []
1268
+ for ent in plan.entities[:3]:
1269
+ for rel in plan.relations[:4]:
1270
+ hist = self.store.history_of(ent, rel, user_id=user_id)
1271
+ if not hist:
1272
+ continue
1273
+ # Sort by valid_from ascending — earliest first.
1274
+ hist_sorted = sorted(
1275
+ hist, key=lambda f: (f.valid_from or "", f.id))
1276
+ if len(hist_sorted) == 1:
1277
+ # Only one value — no chain. Skip.
1278
+ continue
1279
+ lines = [f"TEMPORAL CHAIN: {ent}|{rel}:"]
1280
+ supersessions = 0
1281
+ current_value: str | None = None
1282
+ for i, f in enumerate(hist_sorted):
1283
+ vf = f.valid_from or "?"
1284
+ vt = f.valid_to or "∞"
1285
+ status = ("CURRENT"
1286
+ if f.is_active else "SUPERSEDED")
1287
+ if not f.is_active:
1288
+ supersessions += 1
1289
+ else:
1290
+ current_value = f.value
1291
+ lines.append(f" - {f.value} [valid {vf} → {vt}]"
1292
+ f" ({status})")
1293
+ # Verdict line — gives the BOOL judge a one-shot signal.
1294
+ verdict = (f"→ {supersessions} supersession(s) detected"
1295
+ f" → {ent} changed"
1296
+ if supersessions > 0
1297
+ else f"→ 0 supersessions → {ent} unchanged")
1298
+ if current_value:
1299
+ verdict += f" (current: {current_value})"
1300
+ lines.append(verdict)
1301
+ notes.append("\n".join(lines))
1302
+ return notes
1303
+
1186
1304
  # ------------------------------------------------------------- symbolic
1187
1305
  def _symbolic_query(self, plan: QueryPlan, user_id, agent_id, run_id,
1188
1306
  scope, k, query):
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -219,8 +219,23 @@ class PermissionGate:
219
219
 
220
220
  Required to read ``~/.ssh`` or to run ``curl``. The user must
221
221
  call this explicitly — there is no wildcard.
222
+
223
+ v0.5.2 fix: paths are NORMALIZED on store (the same
224
+ ``_norm_path`` expansion ``can_read`` uses), so ``~/.ssh``
225
+ and ``/home/alice/.ssh`` compare equal. Without this, a
226
+ user could grant_sensitive("~/.ssh/id_rsa") but can_read
227
+ would expand to "/home/alice/.ssh/id_rsa" and miss the
228
+ match — silently breaking the explicit-permission policy
229
+ the gate exists to enforce. Caught by
230
+ TestUserDirectiveNoMaliciousCodeReadsUserData.
222
231
  """
223
- self._explicit_sensitive_allows.add(path_or_cmd.strip())
232
+ # Heuristic: if it looks like a path (contains / or ~), treat
233
+ # as a path and normalize. Otherwise treat as an exec (basename
234
+ # already normalized in can_exec).
235
+ s = path_or_cmd.strip()
236
+ if "/" in s or s.startswith("~"):
237
+ s = _norm_path(s)
238
+ self._explicit_sensitive_allows.add(s)
224
239
  if _audit_log := self.audit:
225
240
  _audit("permission.grant_sensitive", path_or_cmd, _audit_log)
226
241