@heretek-ai/epistemic-swarm 0.4.7 → 0.4.8
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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli.js +59 -7
- package/config/mcp_launcher.py +75 -48
- package/extensions/pi/index.js +25 -5
- package/install.sh +5 -5
- package/package.json +1 -1
- package/plugins/antigravity/plugin.json +1 -1
- package/plugins/gemini/gemini-extension.json +1 -1
- package/plugins/opencode/index.js +308 -142
- package/plugins/opencode/tui.js +7 -9
- package/plugins/research-cache/skills/research_cache/hasher.py +27 -9
- package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +26 -18
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/auditor_engine.py +122 -102
- package/runner/claim_store.py +5 -4
- package/runner/living_dossiers.py +32 -10
- package/runner/mcp_protocol.py +1 -1
- package/runner/mcp_server.py +23 -11
- package/runner/path_safety.py +62 -0
- package/runner/pcrb.py +68 -39
- package/runner/pcrb_verify.py +82 -52
- package/runner/refinement.py +61 -47
- package/runner/research_swarm.py +61 -7
- package/runner/state_machine.py +9 -6
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/test_mcp_server.py +0 -1
- package/runner/tests/test_opencode_ux.py +368 -33
- package/runner/tests/test_swarm.py +20 -9
- package/runner/tests/test_sweep_regressions.py +373 -0
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/auction_experiment.py +38 -30
- package/scripts/bet1_advisory_spike.py +41 -25
- package/scripts/build_adapters.py +52 -37
- package/scripts/divergence_experiment.py +61 -31
- package/skills/brainstorming/scripts/brainstorm.py +3 -2
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/fetch.py +0 -1
- package/skills/epistemic_search/scripts/search.py +202 -73
- package/skills/epistemic_search/scripts/webcache.py +1 -1
- package/skills/grilling/socratic_tree.py +26 -18
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/research_cache/hasher.py +27 -9
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- package/skills/swarm_config/configure.py +32 -28
|
@@ -14,10 +14,14 @@ from datetime import datetime, timezone
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Dict, Any, Optional, Tuple
|
|
16
16
|
|
|
17
|
+
DEFAULT_RESEARCH_DIR = ".research"
|
|
18
|
+
BASE_DIR_HELP = "Base .research directory"
|
|
19
|
+
|
|
17
20
|
class SourceHasher:
|
|
18
21
|
def __init__(self, base_dir: Optional[Path] = None):
|
|
19
|
-
|
|
20
|
-
self.
|
|
22
|
+
target = base_dir or Path(DEFAULT_RESEARCH_DIR)
|
|
23
|
+
self.base_dir = Path(os.path.realpath(str(target)))
|
|
24
|
+
self.sources_dir = Path(os.path.realpath(str(self.base_dir / "sources")))
|
|
21
25
|
self.sources_dir.mkdir(parents=True, exist_ok=True)
|
|
22
26
|
|
|
23
27
|
@staticmethod
|
|
@@ -70,15 +74,28 @@ class SourceHasher:
|
|
|
70
74
|
return None
|
|
71
75
|
|
|
72
76
|
def _resolve_hash_file(self, hash_prefix: str, extension: str) -> Optional[Path]:
|
|
73
|
-
"""Resolves exact or prefix hash to disk path.
|
|
74
|
-
|
|
77
|
+
"""Resolves exact or prefix hash to disk path.
|
|
78
|
+
|
|
79
|
+
``hash_prefix`` is untrusted (it arrives from an LLM tool call), so the
|
|
80
|
+
result is required to stay inside ``sources_dir``. Containment is checked
|
|
81
|
+
inline rather than via ``runner.path_safety`` because this module is
|
|
82
|
+
mirrored into the standalone ``research-cache`` plugin, which has no
|
|
83
|
+
``runner`` package on its path.
|
|
84
|
+
"""
|
|
85
|
+
if not re.match(r"^[a-fA-F0-9]+$", hash_prefix):
|
|
86
|
+
return None
|
|
87
|
+
exact_path = Path(os.path.realpath(str(self.sources_dir / f"{hash_prefix}{extension}")))
|
|
88
|
+
if exact_path.parent != self.sources_dir:
|
|
89
|
+
return None
|
|
75
90
|
if exact_path.exists():
|
|
76
91
|
return exact_path
|
|
77
92
|
|
|
78
93
|
# If prefix match
|
|
79
94
|
matches = list(self.sources_dir.glob(f"{hash_prefix}*{extension}"))
|
|
80
95
|
if len(matches) == 1:
|
|
81
|
-
|
|
96
|
+
candidate = Path(os.path.realpath(str(matches[0])))
|
|
97
|
+
if candidate.parent == self.sources_dir:
|
|
98
|
+
return candidate
|
|
82
99
|
return None
|
|
83
100
|
|
|
84
101
|
@staticmethod
|
|
@@ -143,20 +160,21 @@ def main():
|
|
|
143
160
|
cache_parser.add_argument("--title", default="Untitled", help="Document Title")
|
|
144
161
|
cache_parser.add_argument("--tier", default="WEB_DOCUMENT", help="Source tier")
|
|
145
162
|
cache_parser.add_argument("--content", help="Raw text content (or read from stdin)")
|
|
146
|
-
cache_parser.add_argument("--dir", default=
|
|
163
|
+
cache_parser.add_argument("--dir", default=DEFAULT_RESEARCH_DIR, help=BASE_DIR_HELP)
|
|
147
164
|
|
|
148
165
|
# Verify command
|
|
149
166
|
verify_parser = subparsers.add_parser("verify", help="Verify a verbatim quote")
|
|
150
167
|
verify_parser.add_argument("--hash", required=True, help="Document SHA-256 hash")
|
|
151
168
|
verify_parser.add_argument("--quote", required=True, help="Verbatim quote to check")
|
|
152
|
-
verify_parser.add_argument("--dir", default=
|
|
169
|
+
verify_parser.add_argument("--dir", default=DEFAULT_RESEARCH_DIR, help=BASE_DIR_HELP)
|
|
153
170
|
|
|
154
171
|
# List command
|
|
155
172
|
list_parser = subparsers.add_parser("list", help="List cached sources")
|
|
156
|
-
list_parser.add_argument("--dir", default=
|
|
173
|
+
list_parser.add_argument("--dir", default=DEFAULT_RESEARCH_DIR, help=BASE_DIR_HELP)
|
|
157
174
|
|
|
158
175
|
args = parser.parse_args()
|
|
159
|
-
|
|
176
|
+
dir_val = getattr(args, "dir", DEFAULT_RESEARCH_DIR) or DEFAULT_RESEARCH_DIR
|
|
177
|
+
hasher = SourceHasher(base_dir=Path(os.path.realpath(str(dir_val))))
|
|
160
178
|
|
|
161
179
|
if args.command == "cache":
|
|
162
180
|
content = args.content
|
|
@@ -5,6 +5,7 @@ Implements Matt Pocock-style design tree traversal to isolate the active decisio
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import sys
|
|
8
|
+
import os
|
|
8
9
|
import json
|
|
9
10
|
import argparse
|
|
10
11
|
from pathlib import Path
|
|
@@ -84,7 +85,8 @@ class DesignTree:
|
|
|
84
85
|
return len(self.compute_frontier()) == 0 and all(n.is_settled() for n in self.nodes.values())
|
|
85
86
|
|
|
86
87
|
def export_frontier_json(self, output_path: Path):
|
|
87
|
-
|
|
88
|
+
safe_path = Path(os.path.realpath(str(output_path)))
|
|
89
|
+
safe_path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
90
|
data = {
|
|
89
91
|
"objective": self.objective,
|
|
90
92
|
"is_complete": self.is_complete(),
|
|
@@ -93,12 +95,13 @@ class DesignTree:
|
|
|
93
95
|
k: v.settled_answer for k, v in self.nodes.items() if v.is_settled()
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
|
-
with open(
|
|
98
|
+
with open(safe_path, "w", encoding="utf-8") as f:
|
|
97
99
|
json.dump(data, f, indent=2)
|
|
98
100
|
|
|
99
101
|
@classmethod
|
|
100
102
|
def load_from_json(cls, file_path: Path) -> 'DesignTree':
|
|
101
|
-
|
|
103
|
+
safe_path = Path(os.path.realpath(str(file_path)))
|
|
104
|
+
with open(safe_path, "r", encoding="utf-8") as f:
|
|
102
105
|
data = json.load(f)
|
|
103
106
|
tree = cls(objective=data["objective"])
|
|
104
107
|
for k, v in data.get("nodes", {}).items():
|
|
@@ -106,15 +109,31 @@ class DesignTree:
|
|
|
106
109
|
return tree
|
|
107
110
|
|
|
108
111
|
|
|
112
|
+
def _display_frontier(tree: DesignTree, frontier: list) -> None:
|
|
113
|
+
print(f"\n🎯 Objective: {tree.objective}")
|
|
114
|
+
print(f"📊 Total Nodes: {len(tree.nodes)} | Settled: {sum(1 for n in tree.nodes.values() if n.is_settled())}")
|
|
115
|
+
if not frontier:
|
|
116
|
+
if tree.nodes and tree.is_complete():
|
|
117
|
+
print("✅ Frontier is EMPTY. All prerequisite branches are fully settled!")
|
|
118
|
+
else:
|
|
119
|
+
print("ℹ️ No active frontier nodes. Define new decision nodes to begin grilling.")
|
|
120
|
+
else:
|
|
121
|
+
print(f"\n⚡ Current Active Frontier ({len(frontier)} questions ready):")
|
|
122
|
+
for idx, node in enumerate(frontier, 1):
|
|
123
|
+
print(f"\n❓ Q{idx} [{node.node_id}] - {node.title}")
|
|
124
|
+
print(f" {node.question}")
|
|
125
|
+
print(f" ➡️ Recommended: {node.recommended}")
|
|
126
|
+
|
|
127
|
+
|
|
109
128
|
def main():
|
|
110
129
|
parser = argparse.ArgumentParser(description="Epistemic Swarm Socratic Decision Tree")
|
|
111
130
|
parser.add_argument("--objective", type=str, help="Research objective")
|
|
112
131
|
parser.add_argument("--file", type=str, default=".research/frontier.json", help="Path to frontier.json")
|
|
113
132
|
parser.add_argument("--show-frontier", action="store_true", help="Print the current decision frontier")
|
|
114
133
|
parser.add_argument("--settle", nargs=2, metavar=("NODE_ID", "ANSWER"), help="Settle a decision node")
|
|
115
|
-
|
|
134
|
+
|
|
116
135
|
args = parser.parse_args()
|
|
117
|
-
frontier_path = Path(args.file)
|
|
136
|
+
frontier_path = Path(os.path.realpath(str(args.file)))
|
|
118
137
|
|
|
119
138
|
if frontier_path.exists():
|
|
120
139
|
tree = DesignTree.load_from_json(frontier_path)
|
|
@@ -130,19 +149,8 @@ def main():
|
|
|
130
149
|
|
|
131
150
|
frontier = tree.compute_frontier()
|
|
132
151
|
if args.show_frontier or not args.settle:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if not frontier:
|
|
136
|
-
if tree.nodes and tree.is_complete():
|
|
137
|
-
print("✅ Frontier is EMPTY. All prerequisite branches are fully settled!")
|
|
138
|
-
else:
|
|
139
|
-
print("ℹ️ No active frontier nodes. Define new decision nodes to begin grilling.")
|
|
140
|
-
else:
|
|
141
|
-
print(f"\n⚡ Current Active Frontier ({len(frontier)} questions ready):")
|
|
142
|
-
for idx, node in enumerate(frontier, 1):
|
|
143
|
-
print(f"\n❓ Q{idx} [{node.node_id}] - {node.title}")
|
|
144
|
-
print(f" {node.question}")
|
|
145
|
-
print(f" ➡️ Recommended: {node.recommended}")
|
|
152
|
+
_display_frontier(tree, frontier)
|
|
153
|
+
|
|
146
154
|
|
|
147
155
|
if __name__ == "__main__":
|
|
148
156
|
main()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/runner/auditor_engine.py
CHANGED
|
@@ -17,6 +17,68 @@ from skills.research_cache.hasher import SourceHasher
|
|
|
17
17
|
from runner.state_machine import ResearchStateMachine, ScopeStatus
|
|
18
18
|
from runner.refinement import compute_epistemic_score
|
|
19
19
|
|
|
20
|
+
def _compute_scope_epistemic_score(
|
|
21
|
+
constitution: Any,
|
|
22
|
+
alpha_dossier: Dict[str, Any],
|
|
23
|
+
beta_dossier: Dict[str, Any],
|
|
24
|
+
alpha_results: list,
|
|
25
|
+
beta_results: list,
|
|
26
|
+
counts: Dict[str, int],
|
|
27
|
+
) -> float:
|
|
28
|
+
from runner.refinement import LEGACY_CONSTITUTION
|
|
29
|
+
|
|
30
|
+
if constitution is not LEGACY_CONSTITUTION:
|
|
31
|
+
from runner.claim_witness import STATUS_REJECTED, claims_from_dossier
|
|
32
|
+
from runner.refinement import compute_epistemic_score_from_claims
|
|
33
|
+
|
|
34
|
+
results_by_id = {r["claim_id"]: r for r in (alpha_results + beta_results)}
|
|
35
|
+
score_claims = claims_from_dossier(alpha_dossier) + claims_from_dossier(beta_dossier)
|
|
36
|
+
for c in score_claims:
|
|
37
|
+
r = results_by_id.get(c.claim_id)
|
|
38
|
+
if r is not None and r.get("audited_tag") == "UNVERIFIED_REJECTED":
|
|
39
|
+
c.tag = "UNVERIFIED_REJECTED"
|
|
40
|
+
c.status = STATUS_REJECTED
|
|
41
|
+
epistemic_score, _breakdown = compute_epistemic_score_from_claims(
|
|
42
|
+
score_claims, constitution=constitution
|
|
43
|
+
)
|
|
44
|
+
return epistemic_score
|
|
45
|
+
|
|
46
|
+
epistemic_score, _breakdown = compute_epistemic_score(counts)
|
|
47
|
+
return epistemic_score
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _apply_living_dossiers_degradation(
|
|
51
|
+
base_dir: Path,
|
|
52
|
+
scope_id: str,
|
|
53
|
+
alpha_dossier: Dict[str, Any],
|
|
54
|
+
beta_dossier: Dict[str, Any],
|
|
55
|
+
) -> Dict[str, Any]:
|
|
56
|
+
degradation = {"events": [], "degraded_scopes": []}
|
|
57
|
+
try:
|
|
58
|
+
from runner.living_dossiers import (
|
|
59
|
+
apply_degradation,
|
|
60
|
+
append_ledger,
|
|
61
|
+
load_retractions,
|
|
62
|
+
queue_requeue,
|
|
63
|
+
)
|
|
64
|
+
from runner.claim_witness import claims_from_dossier
|
|
65
|
+
|
|
66
|
+
retractions = load_retractions(base_dir)
|
|
67
|
+
if retractions:
|
|
68
|
+
all_claims = claims_from_dossier(alpha_dossier) + claims_from_dossier(beta_dossier)
|
|
69
|
+
_degraded, events = apply_degradation(all_claims, retractions, scope_id=scope_id)
|
|
70
|
+
if events:
|
|
71
|
+
append_ledger(base_dir, events)
|
|
72
|
+
queue_requeue(base_dir, scope_id, reason="claim degradation (retraction event)")
|
|
73
|
+
degradation = {
|
|
74
|
+
"events": [e.to_dict() for e in events],
|
|
75
|
+
"degraded_scopes": [scope_id] if events else [],
|
|
76
|
+
}
|
|
77
|
+
except Exception as exc: # noqa: BLE001 - degradation must not break audit
|
|
78
|
+
print(f"[auditor] living-dossiers pass skipped: {exc}", file=sys.stderr)
|
|
79
|
+
return degradation
|
|
80
|
+
|
|
81
|
+
|
|
20
82
|
class EpistemicAuditorEngine:
|
|
21
83
|
def __init__(self, base_dir: Optional[Path] = None):
|
|
22
84
|
self.base_dir = base_dir or Path(".research")
|
|
@@ -59,82 +121,24 @@ class EpistemicAuditorEngine:
|
|
|
59
121
|
beta_dossier.get("falsification_claims", []), constitution=constitution
|
|
60
122
|
)
|
|
61
123
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
total_inferred = len(alpha_dossier.get("inferred_implications", []))
|
|
71
|
-
total_hypotheses = len(beta_dossier.get("hypotheses", []))
|
|
124
|
+
counts = {
|
|
125
|
+
"verified": alpha_verified + beta_verified,
|
|
126
|
+
"rejected": alpha_rejected + beta_rejected,
|
|
127
|
+
"inferred": len(alpha_dossier.get("inferred_implications", [])),
|
|
128
|
+
"hypotheses": len(beta_dossier.get("hypotheses", [])),
|
|
129
|
+
"neg_knowledge": len(alpha_dossier.get("negative_knowledge", [])) + len(beta_dossier.get("negative_knowledge", [])),
|
|
130
|
+
}
|
|
72
131
|
|
|
73
132
|
# 3. Calculate Epistemic Score via the pure refinement function.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
# tier weighting is opt-in via Domain Packs (Stream G).
|
|
78
|
-
if constitution is not LEGACY_CONSTITUTION:
|
|
79
|
-
from runner.claim_witness import STATUS_REJECTED, claims_from_dossier
|
|
80
|
-
from runner.refinement import compute_epistemic_score_from_claims
|
|
81
|
-
|
|
82
|
-
results_by_id = {
|
|
83
|
-
r["claim_id"]: r for r in (alpha_results + beta_results)
|
|
84
|
-
}
|
|
85
|
-
score_claims = claims_from_dossier(alpha_dossier) + claims_from_dossier(beta_dossier)
|
|
86
|
-
for c in score_claims:
|
|
87
|
-
r = results_by_id.get(c.claim_id)
|
|
88
|
-
if r is not None and r.get("audited_tag") == "UNVERIFIED_REJECTED":
|
|
89
|
-
# Reflect the auditor's verdict, not the dossier's claim.
|
|
90
|
-
c.tag = "UNVERIFIED_REJECTED"
|
|
91
|
-
c.status = STATUS_REJECTED
|
|
92
|
-
epistemic_score, _breakdown = compute_epistemic_score_from_claims(
|
|
93
|
-
score_claims, constitution=constitution
|
|
94
|
-
)
|
|
95
|
-
else:
|
|
96
|
-
epistemic_score, _breakdown = compute_epistemic_score({
|
|
97
|
-
"verified": total_verified,
|
|
98
|
-
"rejected": total_rejected,
|
|
99
|
-
"inferred": total_inferred,
|
|
100
|
-
"hypotheses": total_hypotheses,
|
|
101
|
-
"neg_knowledge": total_neg_knowledge,
|
|
102
|
-
})
|
|
133
|
+
epistemic_score = _compute_scope_epistemic_score(
|
|
134
|
+
constitution, alpha_dossier, beta_dossier, alpha_results, beta_results, counts
|
|
135
|
+
)
|
|
103
136
|
accept_threshold = constitution.accept_threshold
|
|
104
137
|
|
|
105
|
-
# 3b. Living Dossiers (Stream C):
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
try:
|
|
110
|
-
from runner.living_dossiers import (
|
|
111
|
-
apply_degradation,
|
|
112
|
-
append_ledger,
|
|
113
|
-
load_retractions,
|
|
114
|
-
queue_requeue,
|
|
115
|
-
)
|
|
116
|
-
from runner.claim_witness import claims_from_dossier
|
|
117
|
-
|
|
118
|
-
retractions = load_retractions(self.base_dir)
|
|
119
|
-
if retractions:
|
|
120
|
-
all_claims = (
|
|
121
|
-
claims_from_dossier(alpha_dossier)
|
|
122
|
-
+ claims_from_dossier(beta_dossier)
|
|
123
|
-
)
|
|
124
|
-
_degraded, events = apply_degradation(
|
|
125
|
-
all_claims, retractions, scope_id=scope_id
|
|
126
|
-
)
|
|
127
|
-
if events:
|
|
128
|
-
append_ledger(self.base_dir, events)
|
|
129
|
-
queue_requeue(
|
|
130
|
-
self.base_dir, scope_id, reason="claim degradation (retraction event)"
|
|
131
|
-
)
|
|
132
|
-
degradation = {
|
|
133
|
-
"events": [e.to_dict() for e in events],
|
|
134
|
-
"degraded_scopes": [scope_id] if events else [],
|
|
135
|
-
}
|
|
136
|
-
except Exception as exc: # noqa: BLE001 - degradation must not break audit
|
|
137
|
-
print(f"[auditor] living-dossiers pass skipped: {exc}", file=sys.stderr)
|
|
138
|
+
# 3b. Living Dossiers (Stream C): check degradation against retraction events
|
|
139
|
+
degradation = _apply_living_dossiers_degradation(
|
|
140
|
+
self.base_dir, scope_id, alpha_dossier, beta_dossier
|
|
141
|
+
)
|
|
138
142
|
|
|
139
143
|
# 4. Calculate Divergence Score
|
|
140
144
|
divergence_score, divergence_matrix = self._compute_divergence(alpha_dossier, beta_dossier)
|
|
@@ -146,9 +150,9 @@ class EpistemicAuditorEngine:
|
|
|
146
150
|
"audited_at": datetime.now(timezone.utc).isoformat(),
|
|
147
151
|
"summary": {
|
|
148
152
|
"total_claims_audited": len(alpha_results) + len(beta_results),
|
|
149
|
-
"verified_passed":
|
|
150
|
-
"unverified_rejected":
|
|
151
|
-
"negative_knowledge_count":
|
|
153
|
+
"verified_passed": counts["verified"],
|
|
154
|
+
"unverified_rejected": counts["rejected"],
|
|
155
|
+
"negative_knowledge_count": counts["neg_knowledge"],
|
|
152
156
|
"epistemic_score": epistemic_score,
|
|
153
157
|
"divergence_score": divergence_score,
|
|
154
158
|
"verdict": "CERTIFIED" if epistemic_score >= accept_threshold else "WARNING_LOW_GROUNDING"
|
|
@@ -294,44 +298,60 @@ class EpistemicAuditorEngine:
|
|
|
294
298
|
divergence = round(min(1.0, (contradictions * 2) / total_claims), 2)
|
|
295
299
|
return divergence, matrix
|
|
296
300
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
md.append(f"# Epistemic Synthesis: Scope {scope_id}\n")
|
|
302
|
-
md.append(f"**Audit Verdict**: `{summary['verdict']}` | **Epistemic Score**: `{summary['epistemic_score']}/1.0` | **Divergence Index**: `{summary['divergence_score']}`\n")
|
|
303
|
-
|
|
304
|
-
md.append("## 1. Verified Empirical Grounding")
|
|
305
|
-
for claim in audit["alpha_claims_audit"]:
|
|
301
|
+
@staticmethod
|
|
302
|
+
def _render_verified_section(alpha_audit: list, beta_audit: list) -> List[str]:
|
|
303
|
+
lines = ["## 1. Verified Empirical Grounding"]
|
|
304
|
+
for claim in alpha_audit:
|
|
306
305
|
if claim["audited_tag"] == "VERIFIED":
|
|
307
|
-
|
|
308
|
-
for claim in
|
|
306
|
+
lines.append(f"- `[VERIFIED: {claim['source_hash'][:8]}]` {claim['statement']}")
|
|
307
|
+
for claim in beta_audit:
|
|
309
308
|
if claim["audited_tag"] == "VERIFIED":
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
309
|
+
lines.append(f"- `[VERIFIED: {claim['source_hash'][:8]}]` (Counter-Evidence) {claim['statement']}")
|
|
310
|
+
return lines
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _render_tensions_section(matrix: list) -> List[str]:
|
|
314
|
+
lines = ["\n## 2. Dialectic Tensions & Falsification Audit"]
|
|
315
|
+
if matrix:
|
|
316
|
+
for item in matrix:
|
|
317
|
+
lines.append(f"### Tension: {item['tension_type']}")
|
|
318
|
+
lines.append(f"- **Thesis Assertion**: {item['proponent_claim']}")
|
|
319
|
+
lines.append(f"- **Adversarial Critique**: {item['adversary_critique']}")
|
|
318
320
|
else:
|
|
319
|
-
|
|
321
|
+
lines.append("No active contradictions identified between primary literature and red-team findings.")
|
|
322
|
+
return lines
|
|
320
323
|
|
|
321
|
-
|
|
322
|
-
|
|
324
|
+
@staticmethod
|
|
325
|
+
def _render_rejected_section(claims: list) -> List[str]:
|
|
326
|
+
lines = ["\n## 3. Rejected & Unverified Assertions"]
|
|
327
|
+
rejected = [c for c in claims if c["audited_tag"] == "UNVERIFIED_REJECTED"]
|
|
323
328
|
if rejected:
|
|
324
329
|
for r in rejected:
|
|
325
|
-
|
|
330
|
+
lines.append(f"- ⚠️ **PURGED**: \"{r['statement']}\" — *Reason: {r['reason']}*")
|
|
326
331
|
else:
|
|
327
|
-
|
|
332
|
+
lines.append("Zero claims rejected. 100% of cited assertions verified against source cache.")
|
|
333
|
+
return lines
|
|
328
334
|
|
|
329
|
-
|
|
335
|
+
@staticmethod
|
|
336
|
+
def _render_negative_knowledge_section(alpha: Dict[str, Any], beta: Dict[str, Any]) -> List[str]:
|
|
337
|
+
lines = ["\n## 4. Negative Knowledge Catalog"]
|
|
330
338
|
all_neg = alpha.get("negative_knowledge", []) + beta.get("negative_knowledge", [])
|
|
331
339
|
if all_neg:
|
|
332
340
|
for n in all_neg:
|
|
333
|
-
|
|
341
|
+
lines.append(f"- `[NEGATIVE_KNOWLEDGE: {n['query']}]` {n['finding']}")
|
|
334
342
|
else:
|
|
335
|
-
|
|
343
|
+
lines.append("No negative knowledge declarations logged.")
|
|
344
|
+
return lines
|
|
336
345
|
|
|
346
|
+
def _generate_synthesis_markdown(self, scope_id: str, alpha: Dict[str, Any],
|
|
347
|
+
beta: Dict[str, Any], audit: Dict[str, Any]) -> str:
|
|
348
|
+
summary = audit["summary"]
|
|
349
|
+
md = [
|
|
350
|
+
f"# Epistemic Synthesis: Scope {scope_id}\n",
|
|
351
|
+
f"**Audit Verdict**: `{summary['verdict']}` | **Epistemic Score**: `{summary['epistemic_score']}/1.0` | **Divergence Index**: `{summary['divergence_score']}`\n",
|
|
352
|
+
]
|
|
353
|
+
md.extend(self._render_verified_section(audit["alpha_claims_audit"], audit["beta_claims_audit"]))
|
|
354
|
+
md.extend(self._render_tensions_section(audit.get("divergence_matrix", [])))
|
|
355
|
+
md.extend(self._render_rejected_section(audit["alpha_claims_audit"] + audit["beta_claims_audit"]))
|
|
356
|
+
md.extend(self._render_negative_knowledge_section(alpha, beta))
|
|
337
357
|
return "\n".join(md)
|
package/runner/claim_store.py
CHANGED
|
@@ -22,6 +22,7 @@ need an API call and are out of this phase's scope).
|
|
|
22
22
|
import argparse
|
|
23
23
|
import hashlib
|
|
24
24
|
import json
|
|
25
|
+
import os
|
|
25
26
|
import sqlite3
|
|
26
27
|
import sys
|
|
27
28
|
from pathlib import Path
|
|
@@ -50,8 +51,8 @@ def _has_fts5(conn: sqlite3.Connection) -> bool:
|
|
|
50
51
|
|
|
51
52
|
class ClaimStore:
|
|
52
53
|
def __init__(self, base_dir: Path, db_path: Optional[Path] = None):
|
|
53
|
-
self.base_dir = Path(base_dir)
|
|
54
|
-
self.db_path = db_path
|
|
54
|
+
self.base_dir = Path(os.path.realpath(str(base_dir)))
|
|
55
|
+
self.db_path = Path(os.path.realpath(str(db_path))) if db_path else (self.base_dir / "claims.sqlite")
|
|
55
56
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
57
|
self.conn = sqlite3.connect(str(self.db_path))
|
|
57
58
|
self.conn.row_factory = sqlite3.Row
|
|
@@ -344,7 +345,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
344
345
|
search_p.add_argument("text")
|
|
345
346
|
args = parser.parse_args(argv)
|
|
346
347
|
|
|
347
|
-
base = Path(args.dir)
|
|
348
|
+
base = Path(os.path.realpath(str(args.dir)))
|
|
348
349
|
if args.command == "reindex" or args.command is None:
|
|
349
350
|
print(json.dumps(reindex(base), indent=2))
|
|
350
351
|
return 0
|
|
@@ -354,7 +355,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
354
355
|
store.close()
|
|
355
356
|
print(json.dumps(rows, indent=2, default=str))
|
|
356
357
|
return 0
|
|
357
|
-
return
|
|
358
|
+
return 1
|
|
358
359
|
|
|
359
360
|
|
|
360
361
|
if __name__ == "__main__":
|
|
@@ -21,6 +21,7 @@ Degradation rules (applied after quote verification):
|
|
|
21
21
|
|
|
22
22
|
import json
|
|
23
23
|
import os
|
|
24
|
+
import re
|
|
24
25
|
import sys
|
|
25
26
|
from dataclasses import dataclass, asdict
|
|
26
27
|
from datetime import datetime, timezone
|
|
@@ -38,6 +39,7 @@ from runner.claim_witness import ( # noqa: E402
|
|
|
38
39
|
STATUS_SUSPECT,
|
|
39
40
|
ClaimWitness,
|
|
40
41
|
)
|
|
42
|
+
from runner.path_safety import safe_join # noqa: E402
|
|
41
43
|
|
|
42
44
|
EVENT_RETRACTED = "RETRACTED"
|
|
43
45
|
EVENT_REVISED = "REVISED"
|
|
@@ -83,6 +85,8 @@ def write_retraction(
|
|
|
83
85
|
"""Record a RETRACTED/REVISED event for a cached source."""
|
|
84
86
|
if event not in VALID_EVENTS:
|
|
85
87
|
raise ValueError(f"event must be one of {VALID_EVENTS}, got {event!r}")
|
|
88
|
+
if not re.match(r"^[a-zA-Z0-9_\-\.]+$", source_hash):
|
|
89
|
+
raise ValueError(f"Invalid source hash: {source_hash!r}")
|
|
86
90
|
retr_dir = Path(base_dir) / "retractions"
|
|
87
91
|
retr_dir.mkdir(parents=True, exist_ok=True)
|
|
88
92
|
payload = RetractionEvent(
|
|
@@ -92,7 +96,11 @@ def write_retraction(
|
|
|
92
96
|
note=note,
|
|
93
97
|
supersedes=supersedes,
|
|
94
98
|
)
|
|
95
|
-
|
|
99
|
+
# `source_hash` is untrusted tool input, so the filename is joined under
|
|
100
|
+
# `retractions/` through safe_join (no separators, no bare `..`, and a
|
|
101
|
+
# containment re-check after canonicalisation). The regex above is the
|
|
102
|
+
# input gate; safe_join is the one that holds if the gate is ever loosened.
|
|
103
|
+
path = safe_join(retr_dir, f"{source_hash}.json")
|
|
96
104
|
# Last write wins per hash (a REVISED may follow a RETRACTED); the ledger
|
|
97
105
|
# keeps the full history of claim transitions either way.
|
|
98
106
|
with open(path, "w", encoding="utf-8") as f:
|
|
@@ -258,7 +266,14 @@ def check_staleness(base_dir: Path, scope_ids: Optional[List[str]] = None) -> Di
|
|
|
258
266
|
|
|
259
267
|
if scope_ids is None:
|
|
260
268
|
if not scratch.exists():
|
|
261
|
-
return {
|
|
269
|
+
return {
|
|
270
|
+
"scopes": [],
|
|
271
|
+
"retractions": len(retractions),
|
|
272
|
+
"degraded": 0,
|
|
273
|
+
# Keep the early return shape identical to the normal one so
|
|
274
|
+
# callers can key off `degraded_scopes` unconditionally.
|
|
275
|
+
"degraded_scopes": [],
|
|
276
|
+
}
|
|
262
277
|
scope_dirs = sorted(p for p in scratch.iterdir() if p.is_dir())
|
|
263
278
|
else:
|
|
264
279
|
scope_dirs = [scratch / s for s in scope_ids]
|
|
@@ -281,7 +296,7 @@ def check_staleness(base_dir: Path, scope_ids: Optional[List[str]] = None) -> Di
|
|
|
281
296
|
claim_sets += 1
|
|
282
297
|
dossier = load_dossier(dpath)
|
|
283
298
|
claims = claims_from_dossier(dossier)
|
|
284
|
-
|
|
299
|
+
_, events = apply_degradation(
|
|
285
300
|
claims, retractions, scope_id=scope_id
|
|
286
301
|
)
|
|
287
302
|
all_events.extend(events)
|
|
@@ -336,19 +351,26 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
336
351
|
rep.add_argument("--event", required=True, choices=list(VALID_EVENTS))
|
|
337
352
|
rep.add_argument("--note", default="")
|
|
338
353
|
|
|
339
|
-
sub.add_parser(
|
|
354
|
+
sub.add_parser(
|
|
355
|
+
"check",
|
|
356
|
+
help=(
|
|
357
|
+
"Run one staleness/degradation pass. Exits 1 when any scope "
|
|
358
|
+
"degraded (the ledger reporting a problem IS the outcome), 0 "
|
|
359
|
+
"otherwise — callers under `set -e` should expect that."
|
|
360
|
+
),
|
|
361
|
+
)
|
|
340
362
|
args = parser.parse_args(argv)
|
|
341
363
|
|
|
342
|
-
base = Path(args.dir)
|
|
364
|
+
base = Path(os.path.realpath(str(args.dir)))
|
|
343
365
|
if args.command == "report":
|
|
344
366
|
path = write_retraction(base, args.hash, args.event, note=args.note)
|
|
345
367
|
print(json.dumps({"status": "recorded", "path": str(path)}))
|
|
346
368
|
return 0
|
|
347
|
-
if args.command == "check":
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return
|
|
369
|
+
if args.command == "check" or args.command is None:
|
|
370
|
+
res = check_staleness(base)
|
|
371
|
+
print(json.dumps(res, indent=2))
|
|
372
|
+
return 1 if res.get("degraded_scopes") else 0
|
|
373
|
+
return 1
|
|
352
374
|
|
|
353
375
|
|
|
354
376
|
if __name__ == "__main__":
|
package/runner/mcp_protocol.py
CHANGED
|
@@ -140,7 +140,7 @@ class StdioJsonRpcServer:
|
|
|
140
140
|
return self._error(req_id, -32601, f"Tool {tool_name} not found")
|
|
141
141
|
try:
|
|
142
142
|
text = spec.handler(args)
|
|
143
|
-
except Exception as exc: # noqa: BLE001
|
|
143
|
+
except Exception as exc: # noqa: BLE001 # surface, do not kill the server
|
|
144
144
|
return self._error(req_id, -32603, f"{type(exc).__name__}: {exc}")
|
|
145
145
|
return self._reply(
|
|
146
146
|
req_id, {"content": [{"type": "text", "text": text}]}
|