eduevidence 5.2.0
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/LICENSE +21 -0
- package/README.md +506 -0
- package/README.zh-CN.md +434 -0
- package/SKILL.md +195 -0
- package/bin/eduevidence.js +127 -0
- package/domains/education/manifest.json +183 -0
- package/domains/education/outcome_taxonomy.json +127 -0
- package/domains/manifest.json +26 -0
- package/domains/policy/frame.schema.json +234 -0
- package/domains/policy/manifest.json +10 -0
- package/domains/policy/methodology_checklist.json +109 -0
- package/domains/policy/outcome_taxonomy.json +53 -0
- package/domains/policy/references/causal-identification.md +45 -0
- package/domains/policy/references/cost-evidence.md +44 -0
- package/domains/policy/references/equity.md +42 -0
- package/domains/policy/references/evidence-hierarchy.md +41 -0
- package/domains/policy/references/implementation-evidence.md +47 -0
- package/eduevidence_cli.py +26 -0
- package/engine/__init__.py +11 -0
- package/engine/__pycache__/__init__.cpython-312.pyc +0 -0
- package/engine/__pycache__/analysis.cpython-312.pyc +0 -0
- package/engine/__pycache__/bias.cpython-312.pyc +0 -0
- package/engine/__pycache__/briefs.cpython-312.pyc +0 -0
- package/engine/__pycache__/capabilities.cpython-312.pyc +0 -0
- package/engine/__pycache__/citation_check.cpython-312.pyc +0 -0
- package/engine/__pycache__/contracts.cpython-312.pyc +0 -0
- package/engine/__pycache__/datasets.cpython-312.pyc +0 -0
- package/engine/__pycache__/events.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidence_graph.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidence_review.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidencecore.cpython-312.pyc +0 -0
- package/engine/__pycache__/gap_lens.cpython-312.pyc +0 -0
- package/engine/__pycache__/gaps.cpython-312.pyc +0 -0
- package/engine/__pycache__/graph_store.cpython-312.pyc +0 -0
- package/engine/__pycache__/graph_validate.cpython-312.pyc +0 -0
- package/engine/__pycache__/ids.cpython-312.pyc +0 -0
- package/engine/__pycache__/library.cpython-312.pyc +0 -0
- package/engine/__pycache__/library_builtin.cpython-312.pyc +0 -0
- package/engine/__pycache__/living.cpython-312.pyc +0 -0
- package/engine/__pycache__/log.cpython-312.pyc +0 -0
- package/engine/__pycache__/meta_analysis.cpython-312.pyc +0 -0
- package/engine/__pycache__/meta_synthesis.cpython-312.pyc +0 -0
- package/engine/__pycache__/migration.cpython-312.pyc +0 -0
- package/engine/__pycache__/mode_router.cpython-312.pyc +0 -0
- package/engine/__pycache__/paths.cpython-312.pyc +0 -0
- package/engine/__pycache__/pilot.cpython-312.pyc +0 -0
- package/engine/__pycache__/planner.cpython-312.pyc +0 -0
- package/engine/__pycache__/project.cpython-312.pyc +0 -0
- package/engine/__pycache__/projections.cpython-312.pyc +0 -0
- package/engine/__pycache__/robustness.cpython-312.pyc +0 -0
- package/engine/__pycache__/run.cpython-312.pyc +0 -0
- package/engine/__pycache__/semantics.cpython-312.pyc +0 -0
- package/engine/__pycache__/study_design.cpython-312.pyc +0 -0
- package/engine/__pycache__/synthesis.cpython-312.pyc +0 -0
- package/engine/__pycache__/tribunal.cpython-312.pyc +0 -0
- package/engine/__pycache__/update.cpython-312.pyc +0 -0
- package/engine/__pycache__/versions.cpython-312.pyc +0 -0
- package/engine/analysis.py +308 -0
- package/engine/bias.py +178 -0
- package/engine/briefs.py +106 -0
- package/engine/capabilities.py +99 -0
- package/engine/citation_check.py +192 -0
- package/engine/contracts.py +117 -0
- package/engine/datasets.py +165 -0
- package/engine/events.py +67 -0
- package/engine/evidence_graph.py +571 -0
- package/engine/evidence_review.py +88 -0
- package/engine/evidencecore.py +182 -0
- package/engine/gap_lens.py +132 -0
- package/engine/gaps.py +169 -0
- package/engine/graph_store.py +335 -0
- package/engine/graph_validate.py +87 -0
- package/engine/ids.py +77 -0
- package/engine/library.py +268 -0
- package/engine/library_builtin.py +301 -0
- package/engine/living.py +671 -0
- package/engine/log.py +39 -0
- package/engine/meta_analysis.py +333 -0
- package/engine/meta_synthesis.py +111 -0
- package/engine/migration.py +397 -0
- package/engine/mode_router.py +72 -0
- package/engine/paths.py +15 -0
- package/engine/pilot.py +368 -0
- package/engine/planner.py +126 -0
- package/engine/project.py +118 -0
- package/engine/projections.py +240 -0
- package/engine/robustness.py +109 -0
- package/engine/run.py +85 -0
- package/engine/semantics.py +135 -0
- package/engine/study_design.py +87 -0
- package/engine/synthesis.py +187 -0
- package/engine/tribunal.py +408 -0
- package/engine/update.py +113 -0
- package/engine/versions.py +12 -0
- package/install.sh +510 -0
- package/integrations/__init__.py +1 -0
- package/integrations/__pycache__/__init__.cpython-312.pyc +0 -0
- package/integrations/__pycache__/agent_mcp.cpython-312.pyc +0 -0
- package/integrations/__pycache__/smart_web_fetch.cpython-312.pyc +0 -0
- package/integrations/agent_mcp.py +856 -0
- package/integrations/smart_web_fetch.py +59 -0
- package/package.json +50 -0
- package/pyproject.toml +55 -0
- package/references/applicability-policy.md +88 -0
- package/references/education-framing.md +132 -0
- package/references/effect_size_formulas.md +35 -0
- package/references/evaluation-design.md +111 -0
- package/references/evidence-quality.md +79 -0
- package/references/grade_framework.md +29 -0
- package/references/intervention-design.md +98 -0
- package/references/methodology-audit.md +103 -0
- package/references/outcome-taxonomy.md +106 -0
- package/references/retrieval-protocol.md +142 -0
- package/references/skeptic-protocol.md +93 -0
- package/references/social_science_pitfalls.md +48 -0
- package/references/source-validity.md +140 -0
- package/references/tribunal-policy.md +112 -0
- package/references/wwc_standards.md +29 -0
- package/retrieval/__init__.py +1 -0
- package/retrieval/__pycache__/__init__.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/corpus_store.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/dedupe.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/failures.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/fetch.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/search.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/source.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/validate.cpython-312.pyc +0 -0
- package/retrieval/corpus_store.py +181 -0
- package/retrieval/dedupe.py +127 -0
- package/retrieval/failures.py +90 -0
- package/retrieval/fetch.py +435 -0
- package/retrieval/search.py +493 -0
- package/retrieval/source.py +160 -0
- package/retrieval/validate.py +257 -0
- package/schemas/agent-mcp-approval.schema.json +57 -0
- package/schemas/chart-spec.schema.json +88 -0
- package/schemas/cross-model-review.schema.json +28 -0
- package/schemas/education-frame.schema.json +106 -0
- package/schemas/evaluation.schema.json +35 -0
- package/schemas/evidence.schema.json +81 -0
- package/schemas/fetch-result.schema.json +119 -0
- package/schemas/intervention.schema.json +46 -0
- package/schemas/methodology.schema.json +102 -0
- package/schemas/report-result.schema.json +381 -0
- package/schemas/report-spec.schema.json +130 -0
- package/schemas/source.schema.json +311 -0
- package/schemas/v2/analysis-plan.schema.json +28 -0
- package/schemas/v2/analysis-run.schema.json +33 -0
- package/schemas/v2/claim.schema.json +26 -0
- package/schemas/v2/dataset-asset.schema.json +40 -0
- package/schemas/v2/decision-snapshot.schema.json +53 -0
- package/schemas/v2/evidence-link.schema.json +38 -0
- package/schemas/v2/finding.schema.json +47 -0
- package/schemas/v2/graph-revision.schema.json +30 -0
- package/schemas/v2/knowledge-gap.schema.json +40 -0
- package/schemas/v2/methodology-audit.schema.json +30 -0
- package/schemas/v2/outcome.schema.json +18 -0
- package/schemas/v2/project.schema.json +31 -0
- package/schemas/v2/research-intent.schema.json +24 -0
- package/schemas/v2/run.schema.json +43 -0
- package/schemas/v2/source.schema.json +24 -0
- package/schemas/v2/study-design.schema.json +67 -0
- package/schemas/v2/study.schema.json +37 -0
- package/schemas/v3/pilot-outcome.schema.json +132 -0
- package/schemas/v3/run-manifest.schema.json +193 -0
- package/schemas/v3/synthesis.schema.json +86 -0
- package/schemas/v4/drift-report.schema.json +66 -0
- package/schemas/v4/evidence-library.schema.json +42 -0
- package/schemas/v4/living-subscription.schema.json +55 -0
- package/schemas/v4/meta-analysis.schema.json +152 -0
- package/schemas/verdict.schema.json +56 -0
- package/scripts/__init__.py +0 -0
- package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_evaluator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_judge.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_routing.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_v2.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_v3.cpython-312.pyc +0 -0
- package/scripts/__pycache__/build_result.cpython-312.pyc +0 -0
- package/scripts/__pycache__/claim_audit.cpython-312.pyc +0 -0
- package/scripts/__pycache__/complexity_gate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/compute_confidence.cpython-312.pyc +0 -0
- package/scripts/__pycache__/dashboard_server.cpython-312.pyc +0 -0
- package/scripts/__pycache__/did_regression.cpython-312.pyc +0 -0
- package/scripts/__pycache__/effect_calculator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_matrix.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_score.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_semantics.cpython-312.pyc +0 -0
- package/scripts/__pycache__/fetch_benchmark.cpython-312.pyc +0 -0
- package/scripts/__pycache__/lint_report_layout.cpython-312.pyc +0 -0
- package/scripts/__pycache__/orchestrator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/pre_verdict_gate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/recompute_demo_quality.cpython-312.pyc +0 -0
- package/scripts/__pycache__/render_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/render_report_html.cpython-312.pyc +0 -0
- package/scripts/__pycache__/run_workspace.cpython-312.pyc +0 -0
- package/scripts/__pycache__/skill_lint.cpython-312.pyc +0 -0
- package/scripts/__pycache__/startup_probe.cpython-312.pyc +0 -0
- package/scripts/__pycache__/sync_killer_demo_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.0.2.pyc +0 -0
- package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.1.1.pyc +0 -0
- package/scripts/__pycache__/validate_schema.cpython-312.pyc +0 -0
- package/scripts/audit_dois.py +292 -0
- package/scripts/bake_pack.sh +37 -0
- package/scripts/benchmark.py +183 -0
- package/scripts/benchmark_evaluator.py +371 -0
- package/scripts/benchmark_judge.py +535 -0
- package/scripts/benchmark_routing.py +120 -0
- package/scripts/benchmark_v2.py +304 -0
- package/scripts/benchmark_v3.py +552 -0
- package/scripts/build_esl_artifacts.py +1921 -0
- package/scripts/build_evidence_library.py +307 -0
- package/scripts/build_killer_demo.py +295 -0
- package/scripts/build_result.py +311 -0
- package/scripts/check_version_consistency.py +96 -0
- package/scripts/citation_check.py +123 -0
- package/scripts/claim_audit.py +157 -0
- package/scripts/complexity_gate.py +180 -0
- package/scripts/compute_confidence.py +176 -0
- package/scripts/dashboard_server.py +536 -0
- package/scripts/did_regression.py +315 -0
- package/scripts/effect_calculator.py +99 -0
- package/scripts/enrich_projects_human_and_lieflat.py +315 -0
- package/scripts/evidence_matrix.py +129 -0
- package/scripts/evidence_score.py +234 -0
- package/scripts/evidence_semantics.py +87 -0
- package/scripts/fetch_benchmark.py +177 -0
- package/scripts/generate_metrics.py +99 -0
- package/scripts/generate_new_projects.py +686 -0
- package/scripts/generate_promo.py +192 -0
- package/scripts/lint_report_layout.py +182 -0
- package/scripts/orchestrator.py +1456 -0
- package/scripts/pre_verdict_gate.py +513 -0
- package/scripts/quickstart.py +121 -0
- package/scripts/rebake_all_5themes.py +88 -0
- package/scripts/recompute_demo_quality.py +205 -0
- package/scripts/render_report.py +270 -0
- package/scripts/render_report_html.py +356 -0
- package/scripts/retraction_watch.py +110 -0
- package/scripts/run_workspace.py +337 -0
- package/scripts/serve_web.py +54 -0
- package/scripts/skill_lint.py +150 -0
- package/scripts/startup_probe.py +265 -0
- package/scripts/sync_killer_demo_report.py +270 -0
- package/scripts/test_adversarial_empirical.py +541 -0
- package/scripts/validate_schema.py +256 -0
- package/skill/agents/education-planner.md +80 -0
- package/skill/agents/evaluation-designer.md +74 -0
- package/skill/agents/evidence-analyst.md +106 -0
- package/skill/agents/evidence-judge.md +111 -0
- package/skill/agents/evidence-retriever.md +80 -0
- package/skill/agents/intervention-designer.md +82 -0
- package/skill/agents/method-reviewer.md +104 -0
- package/skill/agents/skeptic.md +89 -0
- package/skill/sub-skills/aihot-trend-analysis/SKILL.md +31 -0
- package/skill/sub-skills/contradiction-analysis/SKILL.md +17 -0
- package/skill/sub-skills/data-analysis/SKILL.md +23 -0
- package/skill/sub-skills/ethics-review/SKILL.md +25 -0
- package/skill/sub-skills/evidence-extraction/SKILL.md +19 -0
- package/skill/sub-skills/evidence-review/SKILL.md +18 -0
- package/skill/sub-skills/gap-analysis/SKILL.md +25 -0
- package/skill/sub-skills/literature-review/SKILL.md +21 -0
- package/skill/sub-skills/methodology-audit/SKILL.md +20 -0
- package/skill/sub-skills/report-generation/SKILL.md +51 -0
- package/skill/sub-skills/research-planning/SKILL.md +21 -0
- package/skill/sub-skills/study-design/SKILL.md +16 -0
- package/skill/task-briefs/adjudicate.md +17 -0
- package/skill/task-briefs/audit.md +15 -0
- package/skill/task-briefs/challenge.md +15 -0
- package/skill/task-briefs/evaluate.md +13 -0
- package/skill/task-briefs/extract.md +16 -0
- package/skill/task-briefs/frame.md +17 -0
- package/skill/task-briefs/intervene.md +14 -0
- package/skill/task-briefs/present.md +16 -0
- package/skill/task-briefs/retrieve.md +15 -0
- package/visualization/eduevidence-report/assets/base.css +337 -0
- package/visualization/eduevidence-report/motion/motion.css +157 -0
- package/visualization/eduevidence-report/motion/motion.js +107 -0
- package/visualization/eduevidence-report/references/bilingual-style.md +7 -0
- package/visualization/eduevidence-report/references/component-catalog.md +145 -0
- package/visualization/eduevidence-report/references/evidence-expansion.md +65 -0
- package/visualization/eduevidence-report/references/full-report-outline.md +86 -0
- package/visualization/eduevidence-report/references/layout-constraints.md +63 -0
- package/visualization/eduevidence-report/references/lieflat-composition.md +79 -0
- package/visualization/eduevidence-report/references/motion-system.md +31 -0
- package/visualization/eduevidence-report/schemas/adapter-envelope.schema.json +22 -0
- package/visualization/eduevidence-report/schemas/visual-layout.schema.json +87 -0
- package/visualization/eduevidence-report/scripts/__pycache__/adapter_contract.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_artifact_manifest.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_charts.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_figures.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_infographics.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_report.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/charts_data.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/lieflat_engine.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/zh_labels.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/adapter_contract.py +72 -0
- package/visualization/eduevidence-report/scripts/build_artifact_manifest.py +70 -0
- package/visualization/eduevidence-report/scripts/build_charts.py +283 -0
- package/visualization/eduevidence-report/scripts/build_figures.py +515 -0
- package/visualization/eduevidence-report/scripts/build_infographics.py +268 -0
- package/visualization/eduevidence-report/scripts/build_report.py +3211 -0
- package/visualization/eduevidence-report/scripts/charts_data.py +617 -0
- package/visualization/eduevidence-report/scripts/check_mobile_layout.js +220 -0
- package/visualization/eduevidence-report/scripts/lieflat_engine.py +852 -0
- package/visualization/eduevidence-report/scripts/zh_labels.py +245 -0
- package/visualization/eduevidence-report/themes/academic.css +94 -0
- package/visualization/eduevidence-report/themes/claude.css +96 -0
- package/visualization/eduevidence-report/themes/datalab-dark.css +147 -0
- package/visualization/eduevidence-report/themes/datalab.css +151 -0
- package/visualization/eduevidence-report/themes/presentation.css +140 -0
package/engine/living.py
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
"""engine/living.py - Living Evidence (v4): subscription, incremental refresh, drift.
|
|
2
|
+
|
|
3
|
+
A LivingEvidenceSubscription binds a revision-bound DecisionSnapshot to the
|
|
4
|
+
project question and a set of query terms. `refresh()` folds newly discovered
|
|
5
|
+
evidence into the evidence graph and re-adjudicates:
|
|
6
|
+
|
|
7
|
+
* no retrieval layer available -> a human/agent injects evidence records
|
|
8
|
+
explicitly (`new_evidence=[...]`);
|
|
9
|
+
* a retrieval layer exists -> a `retriever` adapter callable is
|
|
10
|
+
supplied and returns the same evidence-record shape.
|
|
11
|
+
|
|
12
|
+
Each refresh commits a new graph revision (GraphMutation: source/study/finding/
|
|
13
|
+
evidence_link, optional outcome + methodology audit), runs the tribunal, writes
|
|
14
|
+
a fresh immutable DecisionSnapshot, and emits a drift report under living/drift/
|
|
15
|
+
comparing the tracked snapshot with the new one via `tribunal.decision_diff`.
|
|
16
|
+
|
|
17
|
+
Idempotency: evidence records are deduplicated by a canonical content hash
|
|
18
|
+
(SHA-256 over the caller-supplied record). Re-injecting the same record never
|
|
19
|
+
touches the graph again.
|
|
20
|
+
|
|
21
|
+
Contracts: schemas/v4/living-subscription.schema.json (subscription record),
|
|
22
|
+
schemas/v4/drift-report.schema.json (drift report); graph entities follow
|
|
23
|
+
schemas/v2/*.json (source/study/finding/evidence-link/outcome/methodology-audit).
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import secrets
|
|
30
|
+
import re
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Callable
|
|
34
|
+
|
|
35
|
+
from engine.contracts import validate_record
|
|
36
|
+
from engine.graph_store import GraphMutation, GraphStore
|
|
37
|
+
from engine.ids import new_run_id
|
|
38
|
+
from engine.project import ProjectWorkspace
|
|
39
|
+
from engine.tribunal import adjudicate, decision_diff, save_decision_snapshot
|
|
40
|
+
from engine.versions import METHODOLOGY_POLICY_VERSION
|
|
41
|
+
from scripts.validate_schema import SchemaError, validate
|
|
42
|
+
|
|
43
|
+
def _resolve_v4_schema_dir() -> Path:
|
|
44
|
+
"""Repository layout first; wheel-installed share/ layout as fallback
|
|
45
|
+
(same pattern as engine/contracts._resolve_schema_dir)."""
|
|
46
|
+
repo = Path(__file__).resolve().parent.parent / "schemas" / "v4"
|
|
47
|
+
if repo.is_dir():
|
|
48
|
+
return repo
|
|
49
|
+
import sys
|
|
50
|
+
share = Path(sys.prefix) / "share" / "eduevidence" / "schemas" / "v4"
|
|
51
|
+
if share.is_dir():
|
|
52
|
+
return share
|
|
53
|
+
return repo
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_V4_DIR = _resolve_v4_schema_dir()
|
|
57
|
+
|
|
58
|
+
#: evidence record key -> (graph table, entity schema name)
|
|
59
|
+
_PACKET_TABLES = {
|
|
60
|
+
"source": ("sources", "source"),
|
|
61
|
+
"study": ("studies", "study"),
|
|
62
|
+
"outcome": ("outcomes", "outcome"),
|
|
63
|
+
"finding": ("findings", "finding"),
|
|
64
|
+
"evidence_link": ("evidence_links", "evidence-link"),
|
|
65
|
+
"audit": ("audits", "methodology-audit"),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_RELATION_TO_IMPLICATION = {
|
|
69
|
+
"support": "support_adoption",
|
|
70
|
+
"contradict": "oppose_adoption",
|
|
71
|
+
"neutral": "neutral",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#: minimum keys a caller-supplied evidence record must carry
|
|
75
|
+
_REQUIRED_PACKET_KEYS = ("study", "finding", "evidence_link")
|
|
76
|
+
|
|
77
|
+
_GRAPH_TABLES = ("sources", "studies", "outcomes", "findings",
|
|
78
|
+
"evidence_links", "audits")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _now_iso() -> str:
|
|
82
|
+
return datetime.now(timezone.utc).isoformat()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _local_id(prefix: str, existing: set[str]) -> str:
|
|
86
|
+
"""Local entity ID (prefix-<8 hex>) without touching engine.ids registry."""
|
|
87
|
+
while True:
|
|
88
|
+
candidate = f"{prefix}-{secrets.token_hex(4)}"
|
|
89
|
+
if candidate not in existing:
|
|
90
|
+
return candidate
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _evidence_hash(packet: dict) -> str:
|
|
94
|
+
"""Canonical content hash of a caller-supplied evidence record.
|
|
95
|
+
|
|
96
|
+
Independent of dict key order; two identical records always hash equal,
|
|
97
|
+
which is what makes repeat refresh idempotent. Records must be
|
|
98
|
+
JSON-serializable; note that 100 vs 100.0 hash differently (byte-level
|
|
99
|
+
idempotency only, documented in the module docstring).
|
|
100
|
+
"""
|
|
101
|
+
canonical = json.dumps(packet, sort_keys=True,
|
|
102
|
+
separators=(",", ":"), ensure_ascii=False)
|
|
103
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _validate_v4(record: dict, schema_name: str) -> None:
|
|
107
|
+
schema_path = _V4_DIR / f"{schema_name}.schema.json"
|
|
108
|
+
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
109
|
+
try:
|
|
110
|
+
validate(record, schema)
|
|
111
|
+
except SchemaError as exc:
|
|
112
|
+
raise ValueError(f"invalid {schema_name}: {exc}") from exc
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---- subscription persistence ------------------------------------------
|
|
116
|
+
|
|
117
|
+
def _subscription_path(project: ProjectWorkspace, subscription_id: str) -> Path:
|
|
118
|
+
return project.path / "living" / "subscriptions" / f"{subscription_id}.json"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _load_subscription(project: ProjectWorkspace, subscription_id: str) -> dict:
|
|
122
|
+
if not re.fullmatch(r"SUB-[0-9a-f]{8}", str(subscription_id)):
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"invalid subscription id {subscription_id!r}; expected SUB-<hex8>")
|
|
125
|
+
path = _subscription_path(project, subscription_id)
|
|
126
|
+
if not path.is_file():
|
|
127
|
+
raise FileNotFoundError(
|
|
128
|
+
f"subscription not found: {subscription_id} (missing {path})")
|
|
129
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _save_subscription(project: ProjectWorkspace, subscription: dict) -> Path:
|
|
133
|
+
_validate_v4(subscription, "living-subscription")
|
|
134
|
+
path = _subscription_path(project, subscription["subscription_id"])
|
|
135
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
tmp = path.with_suffix(".json.tmp")
|
|
137
|
+
tmp.write_text(json.dumps(subscription, ensure_ascii=False, indent=2) + "\n",
|
|
138
|
+
encoding="utf-8")
|
|
139
|
+
tmp.replace(path)
|
|
140
|
+
return path
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _write_drift_report(project: ProjectWorkspace, drift: dict) -> Path:
|
|
144
|
+
_validate_v4(drift, "drift-report")
|
|
145
|
+
path = project.path / "living" / "drift" / f"{drift['drift_id']}.json"
|
|
146
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
tmp = path.with_suffix(".json.tmp")
|
|
148
|
+
tmp.write_text(json.dumps(drift, ensure_ascii=False, indent=2) + "\n",
|
|
149
|
+
encoding="utf-8")
|
|
150
|
+
tmp.replace(path)
|
|
151
|
+
return path
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _tracked_snapshot(project: ProjectWorkspace, subscription: dict) -> dict:
|
|
155
|
+
tracked_id = (subscription.get("extensions") or {}).get("last_snapshot_id") \
|
|
156
|
+
or subscription["decision_snapshot_id"]
|
|
157
|
+
path = project.path / "decisions" / f"{tracked_id}.json"
|
|
158
|
+
if not path.is_file():
|
|
159
|
+
raise FileNotFoundError(
|
|
160
|
+
f"tracked decision snapshot {tracked_id!r} missing for subscription "
|
|
161
|
+
f"{subscription['subscription_id']}")
|
|
162
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _dedupe_terms(query_terms: list[str]) -> list[str]:
|
|
166
|
+
"""Trim terms and deduplicate case-insensitively (keep first casing)."""
|
|
167
|
+
seen: set[str] = set()
|
|
168
|
+
terms: list[str] = []
|
|
169
|
+
for term in query_terms:
|
|
170
|
+
key = term.strip().lower()
|
|
171
|
+
if key not in seen:
|
|
172
|
+
seen.add(key)
|
|
173
|
+
terms.append(term.strip())
|
|
174
|
+
return terms
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---- public API --------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def create_subscription(project: ProjectWorkspace, decision_snapshot_id: str,
|
|
180
|
+
query_terms: list[str]) -> dict:
|
|
181
|
+
"""Create a living-evidence subscription bound to a DecisionSnapshot.
|
|
182
|
+
|
|
183
|
+
Validates that the snapshot exists under project/decisions/ (snapshots are
|
|
184
|
+
immutable and are the only thing a subscription may bind to), then writes
|
|
185
|
+
project/living/subscriptions/<id>.json. The subscription question is the
|
|
186
|
+
project question; query_terms describe what new evidence to watch for.
|
|
187
|
+
"""
|
|
188
|
+
if not decision_snapshot_id.startswith("DEC-"):
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"decision snapshot id must start with 'DEC-', got {decision_snapshot_id!r}")
|
|
191
|
+
snapshot_path = project.path / "decisions" / f"{decision_snapshot_id}.json"
|
|
192
|
+
if not snapshot_path.is_file():
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"decision snapshot {decision_snapshot_id} not found in this project; "
|
|
195
|
+
"a subscription must bind to a real adjudication")
|
|
196
|
+
if not query_terms or any(not isinstance(t, str) or not t.strip()
|
|
197
|
+
for t in query_terms):
|
|
198
|
+
raise ValueError("query_terms must be non-empty strings")
|
|
199
|
+
|
|
200
|
+
subs_dir = project.path / "living" / "subscriptions"
|
|
201
|
+
existing = {p.stem for p in subs_dir.glob("SUB-*.json")} if subs_dir.is_dir() else set()
|
|
202
|
+
subscription = {
|
|
203
|
+
"subscription_id": _local_id("SUB", existing),
|
|
204
|
+
"decision_snapshot_id": decision_snapshot_id,
|
|
205
|
+
"question": project.manifest()["question"],
|
|
206
|
+
"query_terms": _dedupe_terms(query_terms),
|
|
207
|
+
"status": "active",
|
|
208
|
+
"created_at": _now_iso(),
|
|
209
|
+
"extensions": {
|
|
210
|
+
"last_snapshot_id": decision_snapshot_id,
|
|
211
|
+
"ingested_evidence_hashes": [],
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
_save_subscription(project, subscription)
|
|
215
|
+
return subscription
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def set_subscription_status(project: ProjectWorkspace, subscription_id: str,
|
|
219
|
+
status: str) -> dict:
|
|
220
|
+
"""Pause or resume a subscription (status: active | paused)."""
|
|
221
|
+
if status not in ("active", "paused"):
|
|
222
|
+
raise ValueError(f"status must be 'active' or 'paused', got {status!r}")
|
|
223
|
+
subscription = _load_subscription(project, subscription_id)
|
|
224
|
+
subscription["status"] = status
|
|
225
|
+
_save_subscription(project, subscription)
|
|
226
|
+
return subscription
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def refresh(project: ProjectWorkspace, subscription_id: str, *,
|
|
230
|
+
new_evidence: list[dict] | None = None,
|
|
231
|
+
retriever: Callable[[dict], list[dict]] | None = None) -> dict:
|
|
232
|
+
"""Refresh a subscription: fold new evidence in, re-adjudicate, drift-report.
|
|
233
|
+
|
|
234
|
+
Evidence sources (exactly one):
|
|
235
|
+
* `new_evidence` - a list of evidence records injected by a human/agent
|
|
236
|
+
when no retrieval layer exists. Each record is a dict with keys
|
|
237
|
+
`study`, `finding`, `evidence_link` (required) and optional
|
|
238
|
+
`source`, `outcome`, `audit`. Graph entity ids may be omitted and
|
|
239
|
+
are then generated; explicit ids must not collide with the graph.
|
|
240
|
+
`evidence_link.claim_id` must reference an existing graph claim
|
|
241
|
+
(claim-binding rule from the V3 pilot seed pattern).
|
|
242
|
+
* `retriever` - an adapter callable `retriever(subscription) -> list[dict]`
|
|
243
|
+
invoked when a retrieval layer exists; returns the same record shape.
|
|
244
|
+
|
|
245
|
+
Returns a dict with the drift report and refresh metadata. Re-injecting an
|
|
246
|
+
already-ingested record (same content hash) never commits a graph change.
|
|
247
|
+
"""
|
|
248
|
+
if new_evidence is not None and retriever is not None:
|
|
249
|
+
raise ValueError("provide exactly one of new_evidence / retriever")
|
|
250
|
+
if new_evidence is None and retriever is None:
|
|
251
|
+
raise ValueError("no evidence source: pass new_evidence or a retriever callable")
|
|
252
|
+
|
|
253
|
+
subscription = _load_subscription(project, subscription_id)
|
|
254
|
+
if subscription.get("status") != "active":
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"subscription {subscription_id} is {subscription.get('status', 'unknown')!r}; "
|
|
257
|
+
"resume it before refresh")
|
|
258
|
+
|
|
259
|
+
if retriever is not None:
|
|
260
|
+
new_evidence = list(retriever(subscription) or [])
|
|
261
|
+
|
|
262
|
+
store = GraphStore(project)
|
|
263
|
+
claims = {c["claim_id"] for c in store.read_table("claims")
|
|
264
|
+
if c.get("status", "active") == "active"}
|
|
265
|
+
if not claims:
|
|
266
|
+
raise ValueError("project graph has no claims; a subscription cannot bind evidence")
|
|
267
|
+
|
|
268
|
+
ingested = set((subscription.get("extensions") or {})
|
|
269
|
+
.get("ingested_evidence_hashes", []))
|
|
270
|
+
fresh_packets: list[tuple[str, dict]] = []
|
|
271
|
+
skipped: list[str] = []
|
|
272
|
+
pending_hashes: set[str] = set()
|
|
273
|
+
seen_in_batch: set[str] = set()
|
|
274
|
+
for packet in new_evidence or []:
|
|
275
|
+
if not isinstance(packet, dict):
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"evidence record must be a dict, got {type(packet).__name__}")
|
|
278
|
+
h = _evidence_hash(packet)
|
|
279
|
+
if h in ingested or h in seen_in_batch:
|
|
280
|
+
skipped.append(h)
|
|
281
|
+
continue
|
|
282
|
+
seen_in_batch.add(h)
|
|
283
|
+
# Idempotent retry (P1-1): a previous refresh may have committed the
|
|
284
|
+
# record's study/finding into the graph but failed before updating the
|
|
285
|
+
# subscription's hash list. Treat graph-presence as ingested and only
|
|
286
|
+
# restore the hash bookkeeping.
|
|
287
|
+
study_id = (packet.get("study") or {}).get("study_id")
|
|
288
|
+
if study_id and store.get("studies", study_id):
|
|
289
|
+
skipped.append(h)
|
|
290
|
+
pending_hashes.add(h)
|
|
291
|
+
continue
|
|
292
|
+
fresh_packets.append((h, packet))
|
|
293
|
+
|
|
294
|
+
if not fresh_packets:
|
|
295
|
+
if pending_hashes:
|
|
296
|
+
_restore_ingested_hashes(project, subscription, pending_hashes)
|
|
297
|
+
return _no_change_refresh(project, subscription, store, skipped)
|
|
298
|
+
|
|
299
|
+
# ---- normalize + validate fresh evidence ---------------------------
|
|
300
|
+
next_revision = store.active_revision() + 1
|
|
301
|
+
mutation, new_hashes, finding_ids, summary_parts = _build_mutation(
|
|
302
|
+
project, store, subscription, fresh_packets, claims, next_revision)
|
|
303
|
+
|
|
304
|
+
revision = store.commit(
|
|
305
|
+
run_id=new_run_id(),
|
|
306
|
+
reason=f"living evidence refresh: {subscription_id}",
|
|
307
|
+
mutation=mutation)
|
|
308
|
+
store.repair_head_mirror()
|
|
309
|
+
|
|
310
|
+
snapshot = adjudicate(store, project=project)
|
|
311
|
+
save_decision_snapshot(project, snapshot)
|
|
312
|
+
|
|
313
|
+
previous = _tracked_snapshot(project, subscription)
|
|
314
|
+
diff = decision_diff(previous, snapshot)
|
|
315
|
+
|
|
316
|
+
suggested = _suggested_action(diff, committed_new_evidence=True)
|
|
317
|
+
summary = _summarize(suggested, diff, previous, snapshot,
|
|
318
|
+
summary_parts, skipped)
|
|
319
|
+
|
|
320
|
+
drift = {
|
|
321
|
+
"drift_id": _local_id("DRF", _existing_drift_ids(project)),
|
|
322
|
+
"subscription_id": subscription_id,
|
|
323
|
+
"from_revision": previous["graph_revision"],
|
|
324
|
+
"to_revision": revision.revision,
|
|
325
|
+
"generated_at": _now_iso(),
|
|
326
|
+
"new_evidence_ids": sorted(finding_ids),
|
|
327
|
+
"summary": summary,
|
|
328
|
+
"suggested_action": suggested,
|
|
329
|
+
"extensions": {
|
|
330
|
+
"evidence_committed": True,
|
|
331
|
+
"new_evidence_hashes": sorted(new_hashes),
|
|
332
|
+
"skipped_duplicate_hashes": sorted(skipped),
|
|
333
|
+
"diff": diff,
|
|
334
|
+
"from_decision_snapshot_id": previous["decision_snapshot_id"],
|
|
335
|
+
"to_decision_snapshot_id": snapshot["decision_snapshot_id"],
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
drift_path = _write_drift_report(project, drift)
|
|
339
|
+
|
|
340
|
+
ext = dict(subscription.get("extensions") or {})
|
|
341
|
+
ext["last_refreshed_at"] = drift["generated_at"]
|
|
342
|
+
ext["last_drift_id"] = drift["drift_id"]
|
|
343
|
+
ext["last_snapshot_id"] = snapshot["decision_snapshot_id"]
|
|
344
|
+
ext["from_graph_revision"] = previous["graph_revision"]
|
|
345
|
+
ext["to_graph_revision"] = revision.revision
|
|
346
|
+
ext["ingested_evidence_hashes"] = sorted(ingested | new_hashes)
|
|
347
|
+
subscription["extensions"] = ext
|
|
348
|
+
_save_subscription(project, subscription)
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
"subscription_id": subscription_id,
|
|
352
|
+
"drift": drift,
|
|
353
|
+
"drift_report_path": str(drift_path),
|
|
354
|
+
"graph_revision": revision.revision,
|
|
355
|
+
"suggested_action": suggested,
|
|
356
|
+
"snapshot": snapshot,
|
|
357
|
+
"new_evidence_ids": sorted(finding_ids),
|
|
358
|
+
"evidence_committed": True,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ---- refresh internals -------------------------------------------------
|
|
363
|
+
|
|
364
|
+
def _existing_drift_ids(project: ProjectWorkspace) -> set[str]:
|
|
365
|
+
drift_dir = project.path / "living" / "drift"
|
|
366
|
+
if not drift_dir.is_dir():
|
|
367
|
+
return set()
|
|
368
|
+
return {p.stem for p in drift_dir.glob("DRF-*.json")}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _build_mutation(project, store, subscription, fresh_packets, claims,
|
|
372
|
+
next_revision) -> tuple[GraphMutation, set[str], set[str], list[str]]:
|
|
373
|
+
"""Normalize + validate each fresh record and assemble one GraphMutation."""
|
|
374
|
+
existing = {t: {row[_ID_KEY[t]] for row in store.read_table(t)}
|
|
375
|
+
for t in _GRAPH_TABLES}
|
|
376
|
+
upserts: dict[str, list[dict]] = {t: [] for t in _GRAPH_TABLES}
|
|
377
|
+
new_hashes: set[str] = set()
|
|
378
|
+
finding_ids: set[str] = set()
|
|
379
|
+
summary_parts: list[str] = []
|
|
380
|
+
|
|
381
|
+
for idx, (content_hash, packet) in enumerate(fresh_packets, start=1):
|
|
382
|
+
label = f"evidence record #{idx}"
|
|
383
|
+
missing = [k for k in _REQUIRED_PACKET_KEYS if k not in packet]
|
|
384
|
+
if missing:
|
|
385
|
+
raise ValueError(
|
|
386
|
+
f"{label} is missing required keys {missing}; an evidence record "
|
|
387
|
+
"must carry study, finding and evidence_link "
|
|
388
|
+
"(source/outcome/audit optional)")
|
|
389
|
+
if not all(isinstance(packet[k], dict) for k in _REQUIRED_PACKET_KEYS):
|
|
390
|
+
raise ValueError(f"{label}: study/finding/evidence_link must be dicts")
|
|
391
|
+
|
|
392
|
+
# --- source (optional; auto project-origin when absent) ----------
|
|
393
|
+
source_upserted = False
|
|
394
|
+
source_pkt = packet.get("source")
|
|
395
|
+
if source_pkt is not None:
|
|
396
|
+
src = dict(source_pkt)
|
|
397
|
+
src_id = src.get("source_id")
|
|
398
|
+
if src_id is None or src_id not in existing["sources"]:
|
|
399
|
+
if src_id is None:
|
|
400
|
+
src_id = _local_id("SRC", existing["sources"])
|
|
401
|
+
src["source_id"] = src_id
|
|
402
|
+
existing["sources"].add(src_id)
|
|
403
|
+
source_upserted = True
|
|
404
|
+
upserts["sources"].append(src)
|
|
405
|
+
else:
|
|
406
|
+
src_id = _local_id("SRC", existing["sources"])
|
|
407
|
+
existing["sources"].add(src_id)
|
|
408
|
+
src = {
|
|
409
|
+
"source_id": src_id,
|
|
410
|
+
"origin": "project",
|
|
411
|
+
"source_type": "living-refresh",
|
|
412
|
+
"canonical_locator": f"project:living/{subscription['subscription_id']}",
|
|
413
|
+
"validation_status": "valid",
|
|
414
|
+
"content_hash": None,
|
|
415
|
+
"extensions": {"subscription_id": subscription["subscription_id"]},
|
|
416
|
+
}
|
|
417
|
+
source_upserted = True
|
|
418
|
+
upserts["sources"].append(src)
|
|
419
|
+
|
|
420
|
+
# --- outcome (optional; reused when the id already exists) -------
|
|
421
|
+
outcome_upserted = False
|
|
422
|
+
outcome = None
|
|
423
|
+
outcome_pkt = packet.get("outcome")
|
|
424
|
+
finding_pkt = packet["finding"]
|
|
425
|
+
out_id = finding_pkt.get("outcome_id")
|
|
426
|
+
if out_id is None and outcome_pkt is not None:
|
|
427
|
+
out_id = outcome_pkt.get("outcome_id") or (
|
|
428
|
+
f"OUT-{outcome_pkt['name']}" if outcome_pkt.get("name") else None)
|
|
429
|
+
if out_id is None:
|
|
430
|
+
raise ValueError(
|
|
431
|
+
f"{label}: finding.outcome_id is required (or provide an 'outcome' "
|
|
432
|
+
"record with a name/outcome_id)")
|
|
433
|
+
if out_id not in existing["outcomes"]:
|
|
434
|
+
if outcome_pkt is None:
|
|
435
|
+
raise ValueError(
|
|
436
|
+
f"{label}: outcome {out_id!r} is not in the graph; provide an "
|
|
437
|
+
"'outcome' record to create it")
|
|
438
|
+
outcome = {
|
|
439
|
+
"outcome_id": out_id,
|
|
440
|
+
"name": outcome_pkt.get("name") or out_id[len("OUT-"):],
|
|
441
|
+
"outcome_type": outcome_pkt.get("outcome_type", "learning"),
|
|
442
|
+
"extensions": outcome_pkt.get("extensions") or {},
|
|
443
|
+
}
|
|
444
|
+
existing["outcomes"].add(out_id)
|
|
445
|
+
outcome_upserted = True
|
|
446
|
+
upserts["outcomes"].append(outcome)
|
|
447
|
+
|
|
448
|
+
# --- study --------------------------------------------------------
|
|
449
|
+
study = dict(packet["study"])
|
|
450
|
+
study_id = study.get("study_id")
|
|
451
|
+
if study_id is None:
|
|
452
|
+
study_id = _local_id("STU", existing["studies"])
|
|
453
|
+
study["study_id"] = study_id
|
|
454
|
+
if study_id in existing["studies"]:
|
|
455
|
+
raise ValueError(
|
|
456
|
+
f"{label}: study {study_id!r} already exists in the graph; "
|
|
457
|
+
"living evidence must introduce a new study")
|
|
458
|
+
existing["studies"].add(study_id)
|
|
459
|
+
study.setdefault("source_ids", [src_id])
|
|
460
|
+
if not study["source_ids"]:
|
|
461
|
+
study["source_ids"] = [src_id]
|
|
462
|
+
study.setdefault("identity_status", "resolved")
|
|
463
|
+
study.setdefault("extensions", {})
|
|
464
|
+
upserts["studies"].append(study)
|
|
465
|
+
|
|
466
|
+
# --- finding -------------------------------------------------------
|
|
467
|
+
finding = dict(packet["finding"])
|
|
468
|
+
finding_id = finding.get("finding_id")
|
|
469
|
+
if finding_id is None:
|
|
470
|
+
finding_id = _local_id("FND", existing["findings"])
|
|
471
|
+
finding["finding_id"] = finding_id
|
|
472
|
+
if finding_id in existing["findings"]:
|
|
473
|
+
raise ValueError(f"{label}: finding {finding_id!r} already exists in the graph")
|
|
474
|
+
existing["findings"].add(finding_id)
|
|
475
|
+
finding.setdefault("study_id", study_id)
|
|
476
|
+
finding.setdefault("outcome_id", out_id)
|
|
477
|
+
finding.setdefault("source_locator", src["canonical_locator"])
|
|
478
|
+
finding.setdefault("extensions", {})
|
|
479
|
+
upserts["findings"].append(finding)
|
|
480
|
+
finding_ids.add(finding_id)
|
|
481
|
+
|
|
482
|
+
# --- evidence link (claim-binding rule) ---------------------------
|
|
483
|
+
link = dict(packet["evidence_link"])
|
|
484
|
+
link_id = link.get("evidence_link_id")
|
|
485
|
+
if link_id is None:
|
|
486
|
+
link_id = _local_id("LNK", existing["evidence_links"])
|
|
487
|
+
link["evidence_link_id"] = link_id
|
|
488
|
+
if link_id in existing["evidence_links"]:
|
|
489
|
+
raise ValueError(
|
|
490
|
+
f"{label}: evidence link {link_id!r} already exists in the graph")
|
|
491
|
+
existing["evidence_links"].add(link_id)
|
|
492
|
+
link.setdefault("finding_id", finding_id)
|
|
493
|
+
claim_id = link.get("claim_id")
|
|
494
|
+
if claim_id not in claims:
|
|
495
|
+
raise ValueError(
|
|
496
|
+
f"{label}: claim {claim_id!r} not found in project graph; "
|
|
497
|
+
"an evidence link must bind to an existing claim "
|
|
498
|
+
"(claim-binding rule)")
|
|
499
|
+
relation = link.get("relation_to_claim")
|
|
500
|
+
if relation not in _RELATION_TO_IMPLICATION:
|
|
501
|
+
raise ValueError(
|
|
502
|
+
f"{label}: relation_to_claim must be one of "
|
|
503
|
+
f"{sorted(_RELATION_TO_IMPLICATION)}, got {relation!r}")
|
|
504
|
+
link.setdefault("decision_implication", _RELATION_TO_IMPLICATION[relation])
|
|
505
|
+
link.setdefault("directness", 2)
|
|
506
|
+
link.setdefault("applicability", {"scope_match": "direct"})
|
|
507
|
+
link.setdefault("reasoning_note",
|
|
508
|
+
f"living evidence refresh: {subscription['subscription_id']}")
|
|
509
|
+
link["created_in_revision"] = link.get("created_in_revision") or next_revision
|
|
510
|
+
link.setdefault("extensions", {})
|
|
511
|
+
upserts["evidence_links"].append(link)
|
|
512
|
+
|
|
513
|
+
# --- optional methodology audit -----------------------------------
|
|
514
|
+
audit_upserted = False
|
|
515
|
+
audit_pkt = packet.get("audit")
|
|
516
|
+
if audit_pkt is not None:
|
|
517
|
+
audit = dict(audit_pkt)
|
|
518
|
+
audit_id = audit.get("audit_id")
|
|
519
|
+
if audit_id is None:
|
|
520
|
+
audit_id = _local_id("AUD", existing["audits"])
|
|
521
|
+
audit["audit_id"] = audit_id
|
|
522
|
+
if audit_id in existing["audits"]:
|
|
523
|
+
raise ValueError(f"{label}: audit {audit_id!r} already exists in the graph")
|
|
524
|
+
existing["audits"].add(audit_id)
|
|
525
|
+
audit.setdefault("study_id", study_id)
|
|
526
|
+
audit.setdefault("policy_version", METHODOLOGY_POLICY_VERSION)
|
|
527
|
+
audit.setdefault("audited_at", _now_iso())
|
|
528
|
+
audit.setdefault("bias_checks", [])
|
|
529
|
+
audit.setdefault("confounders", [])
|
|
530
|
+
audit.setdefault("limitations", [])
|
|
531
|
+
audit.setdefault("extensions", {})
|
|
532
|
+
audit_upserted = True
|
|
533
|
+
upserts["audits"].append(audit)
|
|
534
|
+
|
|
535
|
+
# --- schema validation of every entity this record upserts --------
|
|
536
|
+
to_validate: list[tuple[str, dict]] = []
|
|
537
|
+
if source_upserted:
|
|
538
|
+
to_validate.append(("source", src))
|
|
539
|
+
to_validate.append(("study", study))
|
|
540
|
+
if outcome_upserted:
|
|
541
|
+
to_validate.append(("outcome", outcome))
|
|
542
|
+
to_validate.append(("finding", finding))
|
|
543
|
+
to_validate.append(("evidence_link", link))
|
|
544
|
+
if audit_upserted:
|
|
545
|
+
to_validate.append(("audit", audit))
|
|
546
|
+
errors: list[str] = []
|
|
547
|
+
for key, entity in to_validate:
|
|
548
|
+
table, schema_name = _PACKET_TABLES[key]
|
|
549
|
+
for err in validate_record(schema_name, entity):
|
|
550
|
+
errors.append(f"{label} {key}: {err}")
|
|
551
|
+
if errors:
|
|
552
|
+
raise ValueError("invalid living evidence record(s):\n- " +
|
|
553
|
+
"\n- ".join(errors))
|
|
554
|
+
|
|
555
|
+
new_hashes.add(content_hash)
|
|
556
|
+
summary_parts.append(
|
|
557
|
+
f"{label}: study {study_id}, finding {finding_id}, link {link_id} "
|
|
558
|
+
f"(claim {claim_id}, {relation})")
|
|
559
|
+
|
|
560
|
+
return GraphMutation(upserts=upserts), new_hashes, finding_ids, summary_parts
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
_ID_KEY = {
|
|
564
|
+
"sources": "source_id", "studies": "study_id", "outcomes": "outcome_id",
|
|
565
|
+
"findings": "finding_id", "evidence_links": "evidence_link_id",
|
|
566
|
+
"audits": "audit_id",
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _suggested_action(diff: dict, *, committed_new_evidence: bool) -> str:
|
|
571
|
+
"""suggested_action rules:
|
|
572
|
+
|
|
573
|
+
action/confidence change -> 'changed'
|
|
574
|
+
new evidence committed but adjudication unchanged -> 'needs_review'
|
|
575
|
+
otherwise -> 'confirmed'
|
|
576
|
+
"""
|
|
577
|
+
if diff.get("action_changed") or diff.get("confidence_changed"):
|
|
578
|
+
return "changed"
|
|
579
|
+
if committed_new_evidence:
|
|
580
|
+
return "needs_review"
|
|
581
|
+
return "confirmed"
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _summarize(suggested: str, diff: dict, previous: dict, snapshot: dict,
|
|
585
|
+
summary_parts: list[str], skipped: list[str]) -> str:
|
|
586
|
+
parts: list[str] = []
|
|
587
|
+
if summary_parts:
|
|
588
|
+
parts.append(f"{len(summary_parts)} new evidence record(s) committed")
|
|
589
|
+
if diff.get("action_changed"):
|
|
590
|
+
parts.append(
|
|
591
|
+
f"decision action changed: {previous.get('decision')} -> "
|
|
592
|
+
f"{snapshot.get('decision')}")
|
|
593
|
+
if diff.get("confidence_changed"):
|
|
594
|
+
parts.append(
|
|
595
|
+
f"confidence label changed: {previous.get('confidence_label')} -> "
|
|
596
|
+
f"{snapshot.get('confidence_label')}")
|
|
597
|
+
if diff.get("changed_claims"):
|
|
598
|
+
parts.append(f"changed claims: {sorted(diff['changed_claims'])}")
|
|
599
|
+
if diff.get("new_key_evidence_links"):
|
|
600
|
+
parts.append(
|
|
601
|
+
f"new key evidence links: {len(diff['new_key_evidence_links'])}")
|
|
602
|
+
if skipped:
|
|
603
|
+
parts.append(f"{len(skipped)} duplicate record(s) skipped (content-hash dedupe)")
|
|
604
|
+
if not parts:
|
|
605
|
+
parts.append("no substantive change")
|
|
606
|
+
return "; ".join(parts)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _restore_ingested_hashes(project, subscription, hashes: set[str]) -> None:
|
|
610
|
+
"""Best-effort state recovery (P1-1): a refresh whose graph commit
|
|
611
|
+
succeeded but whose subscription bookkeeping failed left the hashes
|
|
612
|
+
unrecorded; on retry the records are already in the graph, so we only
|
|
613
|
+
need to restore the hash list."""
|
|
614
|
+
ext = dict(subscription.get("extensions") or {})
|
|
615
|
+
ext["ingested_evidence_hashes"] = sorted(
|
|
616
|
+
set(ext.get("ingested_evidence_hashes", [])) | hashes)
|
|
617
|
+
subscription["extensions"] = ext
|
|
618
|
+
_save_subscription(project, subscription)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _no_change_refresh(project, subscription, store, skipped) -> dict:
|
|
622
|
+
"""Refresh with no new evidence: emit a 'confirmed' drift report only.
|
|
623
|
+
|
|
624
|
+
No graph revision is created; from_revision == to_revision.
|
|
625
|
+
"""
|
|
626
|
+
previous = _tracked_snapshot(project, subscription)
|
|
627
|
+
diff = decision_diff(previous, previous)
|
|
628
|
+
current_rev = store.active_revision()
|
|
629
|
+
now = _now_iso()
|
|
630
|
+
|
|
631
|
+
summary = ("refresh received no new evidence" if not skipped else
|
|
632
|
+
f"refresh received {len(skipped)} evidence record(s), all already "
|
|
633
|
+
"ingested (content-hash dedupe)")
|
|
634
|
+
drift = {
|
|
635
|
+
"drift_id": _local_id("DRF", _existing_drift_ids(project)),
|
|
636
|
+
"subscription_id": subscription["subscription_id"],
|
|
637
|
+
"from_revision": current_rev,
|
|
638
|
+
"to_revision": current_rev,
|
|
639
|
+
"generated_at": now,
|
|
640
|
+
"new_evidence_ids": [],
|
|
641
|
+
"summary": summary + "; no graph change",
|
|
642
|
+
"suggested_action": "confirmed",
|
|
643
|
+
"extensions": {
|
|
644
|
+
"evidence_committed": False,
|
|
645
|
+
"new_evidence_hashes": [],
|
|
646
|
+
"skipped_duplicate_hashes": sorted(skipped),
|
|
647
|
+
"diff": diff,
|
|
648
|
+
"from_decision_snapshot_id": previous["decision_snapshot_id"],
|
|
649
|
+
"to_decision_snapshot_id": previous["decision_snapshot_id"],
|
|
650
|
+
},
|
|
651
|
+
}
|
|
652
|
+
drift_path = _write_drift_report(project, drift)
|
|
653
|
+
|
|
654
|
+
ext = dict(subscription.get("extensions") or {})
|
|
655
|
+
ext["last_refreshed_at"] = now
|
|
656
|
+
ext["last_drift_id"] = drift["drift_id"]
|
|
657
|
+
ext["from_graph_revision"] = current_rev
|
|
658
|
+
ext["to_graph_revision"] = current_rev
|
|
659
|
+
subscription["extensions"] = ext
|
|
660
|
+
_save_subscription(project, subscription)
|
|
661
|
+
|
|
662
|
+
return {
|
|
663
|
+
"subscription_id": subscription["subscription_id"],
|
|
664
|
+
"drift": drift,
|
|
665
|
+
"drift_report_path": str(drift_path),
|
|
666
|
+
"graph_revision": current_rev,
|
|
667
|
+
"suggested_action": "confirmed",
|
|
668
|
+
"snapshot": None,
|
|
669
|
+
"new_evidence_ids": [],
|
|
670
|
+
"evidence_committed": False,
|
|
671
|
+
}
|