noether-physics 0.1.0__py3-none-any.whl
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.
- noether/__init__.py +11 -0
- noether/answer/__init__.py +0 -0
- noether/answer/pipeline.py +244 -0
- noether/answer/records.py +208 -0
- noether/bench.py +267 -0
- noether/cassettes.py +167 -0
- noether/cli.py +611 -0
- noether/cli_explain.py +149 -0
- noether/compute/__init__.py +0 -0
- noether/compute/normalise.py +414 -0
- noether/compute/plots.py +155 -0
- noether/compute/run.py +215 -0
- noether/compute/symbolic.py +313 -0
- noether/compute/units.py +280 -0
- noether/config.py +100 -0
- noether/draft.py +183 -0
- noether/explain/__init__.py +0 -0
- noether/explain/compose.py +244 -0
- noether/explain/concepts.py +143 -0
- noether/explain/library.py +496 -0
- noether/export.py +209 -0
- noether/index/__init__.py +0 -0
- noether/index/chunk.py +80 -0
- noether/index/embed.py +106 -0
- noether/index/retrieve.py +145 -0
- noether/index/store.py +384 -0
- noether/ingest/__init__.py +0 -0
- noether/ingest/bibliography.py +184 -0
- noether/ingest/document.py +195 -0
- noether/ingest/latex.py +439 -0
- noether/ingest/preprocess.py +347 -0
- noether/library.py +228 -0
- noether/maps.py +226 -0
- noether/mcp_server.py +434 -0
- noether/netclient.py +215 -0
- noether/provenance/__init__.py +0 -0
- noether/provenance/bundle.py +184 -0
- noether/sim/__init__.py +0 -0
- noether/sim/classical.py +271 -0
- noether/sim/engine.py +163 -0
- noether/sim/models.py +114 -0
- noether/sim/quantum.py +339 -0
- noether/sim/result.py +121 -0
- noether/sources/__init__.py +0 -0
- noether/sources/arxiv.py +279 -0
- noether/sources/registry.py +79 -0
- noether/sources/resolvers.py +270 -0
- noether/verify/__init__.py +0 -0
- noether/verify/citations.py +313 -0
- noether/verify/gate.py +221 -0
- noether/verify/matching.py +304 -0
- noether/watch.py +146 -0
- noether_physics-0.1.0.dist-info/METADATA +193 -0
- noether_physics-0.1.0.dist-info/RECORD +57 -0
- noether_physics-0.1.0.dist-info/WHEEL +4 -0
- noether_physics-0.1.0.dist-info/entry_points.txt +2 -0
- noether_physics-0.1.0.dist-info/licenses/LICENSE +21 -0
noether/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""NOETHER — a verifiable research-paper assistant for physics.
|
|
2
|
+
|
|
3
|
+
Four guarantees hold across every code path that emits an answer:
|
|
4
|
+
|
|
5
|
+
1. No unresolvable citation ever leaves the system.
|
|
6
|
+
2. Every claim is anchored to a verbatim span in a real source.
|
|
7
|
+
3. Every equation is parsed, dimensionally checked, and runnable.
|
|
8
|
+
4. Every session is reproducible from a content-hashed record.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""The answering pipeline: gather evidence, judge a draft, record the result.
|
|
2
|
+
|
|
3
|
+
The division of labour is the point. NOETHER does not write prose and does not
|
|
4
|
+
call a language model. It:
|
|
5
|
+
|
|
6
|
+
1. **gathers** passages that provably exist in the corpus (``gather_evidence``),
|
|
7
|
+
2. **judges** whatever claims come back against those passages (``submit``),
|
|
8
|
+
3. **records** what survived, what did not, and why.
|
|
9
|
+
|
|
10
|
+
Claude -- or any caller -- does the writing. That separation is what makes the
|
|
11
|
+
guarantees enforceable: the component that could hallucinate is not the
|
|
12
|
+
component that decides what ships.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from ..compute.symbolic import parse_equation
|
|
23
|
+
from ..compute.units import check_equation
|
|
24
|
+
from ..index.retrieve import Retriever
|
|
25
|
+
from ..index.store import Store
|
|
26
|
+
from ..verify.gate import Claim, Gate
|
|
27
|
+
from .records import (
|
|
28
|
+
AnswerRecord,
|
|
29
|
+
EvidenceItem,
|
|
30
|
+
EvidencePack,
|
|
31
|
+
RecordedClaim,
|
|
32
|
+
RecordedEquation,
|
|
33
|
+
new_answer_id,
|
|
34
|
+
utc_now,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class DraftClaim:
|
|
40
|
+
"""A claim as submitted by a caller: text plus the evidence it cites."""
|
|
41
|
+
|
|
42
|
+
text: str
|
|
43
|
+
evidence_ids: list[str] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def corpus_hash(store: Store) -> str:
|
|
47
|
+
"""A hash over which papers are in the corpus and their content.
|
|
48
|
+
|
|
49
|
+
Part of guarantee 4: an answer is only reproducible against the corpus it
|
|
50
|
+
was computed from, so the record has to name that corpus exactly.
|
|
51
|
+
"""
|
|
52
|
+
digest = hashlib.sha256()
|
|
53
|
+
with store.connect() as conn:
|
|
54
|
+
rows = conn.execute(
|
|
55
|
+
"SELECT paper_id, content_hash FROM papers ORDER BY paper_id"
|
|
56
|
+
).fetchall()
|
|
57
|
+
for row in rows:
|
|
58
|
+
digest.update(row["paper_id"].encode("utf-8"))
|
|
59
|
+
digest.update(row["content_hash"].encode("utf-8"))
|
|
60
|
+
return digest.hexdigest()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def gather_evidence(
|
|
64
|
+
store: Store,
|
|
65
|
+
question: str,
|
|
66
|
+
limit: int = 12,
|
|
67
|
+
retriever: Retriever | None = None,
|
|
68
|
+
) -> EvidencePack:
|
|
69
|
+
"""Retrieve passages relevant to a question, ready to be cited.
|
|
70
|
+
|
|
71
|
+
Each item gets a short stable id so a caller can reference it precisely
|
|
72
|
+
rather than by quoting text back at us -- quoting back is exactly where a
|
|
73
|
+
paraphrase silently becomes a fabricated quote.
|
|
74
|
+
"""
|
|
75
|
+
retriever = retriever or Retriever(store)
|
|
76
|
+
hits = retriever.search(question, limit=limit)
|
|
77
|
+
|
|
78
|
+
items: list[EvidenceItem] = []
|
|
79
|
+
papers: dict[str, str] = {}
|
|
80
|
+
for index, hit in enumerate(hits, start=1):
|
|
81
|
+
chunk = hit.chunk
|
|
82
|
+
items.append(
|
|
83
|
+
EvidenceItem(
|
|
84
|
+
evidence_id=f"e{index}",
|
|
85
|
+
paper_id=chunk.paper_id,
|
|
86
|
+
section_path=chunk.section_path,
|
|
87
|
+
quote=chunk.text,
|
|
88
|
+
start=chunk.start,
|
|
89
|
+
end=chunk.end,
|
|
90
|
+
kind=chunk.kind,
|
|
91
|
+
score=round(hit.score, 6),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
if chunk.paper_id not in papers:
|
|
95
|
+
row = store.paper_row(chunk.paper_id)
|
|
96
|
+
papers[chunk.paper_id] = row["title"] if row else chunk.paper_id
|
|
97
|
+
|
|
98
|
+
return EvidencePack(
|
|
99
|
+
question=question, items=items, papers=papers, corpus_hash=corpus_hash(store)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def submit(
|
|
104
|
+
store: Store,
|
|
105
|
+
pack: EvidencePack,
|
|
106
|
+
drafts: list[DraftClaim],
|
|
107
|
+
equations: list[dict[str, Any]] | None = None,
|
|
108
|
+
references: dict[str, bool] | None = None,
|
|
109
|
+
answers_dir: Path | None = None,
|
|
110
|
+
) -> AnswerRecord:
|
|
111
|
+
"""Judge a draft against the evidence and produce a record.
|
|
112
|
+
|
|
113
|
+
Claims citing evidence that does not exist, or whose quote no longer matches
|
|
114
|
+
the corpus, are dropped by the gate and listed in ``record.dropped``. A thin
|
|
115
|
+
answer is visibly thin; it is never padded to look complete.
|
|
116
|
+
"""
|
|
117
|
+
gate = Gate(store)
|
|
118
|
+
|
|
119
|
+
claims: list[Claim] = []
|
|
120
|
+
unknown_ids: dict[str, str] = {}
|
|
121
|
+
for draft in drafts:
|
|
122
|
+
anchors = []
|
|
123
|
+
for evidence_id in draft.evidence_ids:
|
|
124
|
+
item = pack.by_id(evidence_id)
|
|
125
|
+
if item is None:
|
|
126
|
+
unknown_ids[draft.text] = evidence_id
|
|
127
|
+
continue
|
|
128
|
+
anchors.append(item.to_anchor())
|
|
129
|
+
claims.append(Claim(text=draft.text, anchors=anchors))
|
|
130
|
+
|
|
131
|
+
report = gate.check(claims, references=references)
|
|
132
|
+
|
|
133
|
+
record = AnswerRecord(
|
|
134
|
+
answer_id=new_answer_id(pack.question),
|
|
135
|
+
question=pack.question,
|
|
136
|
+
claims=[RecordedClaim.from_anchors(c.text, c.anchors) for c in report.accepted],
|
|
137
|
+
references=dict(references or {}),
|
|
138
|
+
corpus_hash=pack.corpus_hash,
|
|
139
|
+
created_at=utc_now(),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
for rejection in report.rejected:
|
|
143
|
+
detail = rejection.detail
|
|
144
|
+
if rejection.claim.text in unknown_ids:
|
|
145
|
+
detail = f"cited evidence id {unknown_ids[rejection.claim.text]!r}, which was not offered"
|
|
146
|
+
record.dropped.append(
|
|
147
|
+
{"claim": rejection.claim.text, "reason": rejection.reason, "detail": detail}
|
|
148
|
+
)
|
|
149
|
+
for key, reason in report.dropped_references:
|
|
150
|
+
record.dropped.append({"claim": "", "reason": "unresolved-reference", "detail": f"{key}: {reason}"})
|
|
151
|
+
|
|
152
|
+
for entry in equations or []:
|
|
153
|
+
record.equations.append(_check_equation_entry(entry))
|
|
154
|
+
|
|
155
|
+
if answers_dir is not None:
|
|
156
|
+
record.save(answers_dir)
|
|
157
|
+
return record
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _check_equation_entry(entry: dict[str, Any]) -> RecordedEquation:
|
|
161
|
+
"""Parse and dimension-check one equation offered for an answer."""
|
|
162
|
+
latex = entry.get("latex", "")
|
|
163
|
+
parsed = parse_equation(latex)
|
|
164
|
+
check = check_equation(parsed, entry.get("units"))
|
|
165
|
+
return RecordedEquation(
|
|
166
|
+
latex=latex,
|
|
167
|
+
paper_id=entry.get("paper_id", ""),
|
|
168
|
+
number=entry.get("number"),
|
|
169
|
+
parsed=parsed.ok,
|
|
170
|
+
dimensions=check.verdict.value,
|
|
171
|
+
detail=check.detail or parsed.error,
|
|
172
|
+
notes=list(parsed.notes),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def render_markdown(record: AnswerRecord) -> str:
|
|
177
|
+
"""Render a record for a terminal or a document.
|
|
178
|
+
|
|
179
|
+
Anchors are shown inline, because a citation the reader cannot check on the
|
|
180
|
+
spot is only marginally better than no citation at all.
|
|
181
|
+
"""
|
|
182
|
+
lines = [f"# {record.question}", ""]
|
|
183
|
+
|
|
184
|
+
if not record.claims:
|
|
185
|
+
lines += [
|
|
186
|
+
"_No claim in this answer survived verification._",
|
|
187
|
+
"",
|
|
188
|
+
"That is a result, not an error: the corpus does not support an answer to",
|
|
189
|
+
"this question. Ingest more papers, or ask something the corpus covers.",
|
|
190
|
+
"",
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
for index, claim in enumerate(record.claims, start=1):
|
|
194
|
+
lines.append(f"{index}. {claim.text}")
|
|
195
|
+
for anchor in claim.anchors:
|
|
196
|
+
quote = anchor["quote"]
|
|
197
|
+
trimmed = quote if len(quote) <= 160 else quote[:157] + "..."
|
|
198
|
+
location = anchor["section_path"] or "no section"
|
|
199
|
+
lines.append(f" > {trimmed}")
|
|
200
|
+
lines.append(
|
|
201
|
+
f" -- {anchor['paper_id']}, {location}, "
|
|
202
|
+
f"chars {anchor['start']}-{anchor['end']}"
|
|
203
|
+
)
|
|
204
|
+
lines.append("")
|
|
205
|
+
|
|
206
|
+
if record.equations:
|
|
207
|
+
lines += ["## Equations", ""]
|
|
208
|
+
for equation in record.equations:
|
|
209
|
+
status = "parsed" if equation.parsed else "did not parse"
|
|
210
|
+
lines.append(f"- `{equation.latex}`")
|
|
211
|
+
lines.append(f" {status}; dimensions: {equation.dimensions}")
|
|
212
|
+
if equation.detail:
|
|
213
|
+
lines.append(f" {equation.detail}")
|
|
214
|
+
for note in equation.notes:
|
|
215
|
+
lines.append(f" note: {note}")
|
|
216
|
+
lines.append("")
|
|
217
|
+
|
|
218
|
+
if record.references:
|
|
219
|
+
resolved = record.resolved_count
|
|
220
|
+
lines += ["## References", "", f"{resolved}/{len(record.references)} resolved"]
|
|
221
|
+
for key, ok in sorted(record.references.items()):
|
|
222
|
+
lines.append(f"- {'OK ' if ok else 'UNRESOLVED'} {key}")
|
|
223
|
+
lines.append("")
|
|
224
|
+
|
|
225
|
+
if record.dropped:
|
|
226
|
+
lines += ["## Dropped by the gate", ""]
|
|
227
|
+
for item in record.dropped:
|
|
228
|
+
subject = item["claim"][:80] or item["detail"]
|
|
229
|
+
lines.append(f"- [{item['reason']}] {subject}")
|
|
230
|
+
if item["claim"] and item["detail"]:
|
|
231
|
+
lines.append(f" {item['detail']}")
|
|
232
|
+
lines.append("")
|
|
233
|
+
|
|
234
|
+
# The scope statement sits beside the claims, not buried in the docs:
|
|
235
|
+
# an anchored claim is one whose evidence exists, not one shown to follow.
|
|
236
|
+
lines += [
|
|
237
|
+
"---",
|
|
238
|
+
"Checked: every quote above is verbatim in the cited paper and every span resolves.",
|
|
239
|
+
"NOT checked: whether each quote supports the claim drawn from it. A claim can",
|
|
240
|
+
"contradict its own evidence and still appear here. Read the quotes.",
|
|
241
|
+
"",
|
|
242
|
+
f"corpus {record.corpus_hash[:12]} · {record.summary()}",
|
|
243
|
+
]
|
|
244
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Answer records -- the artifact an answer actually is.
|
|
2
|
+
|
|
3
|
+
An answer here is not a string. It is a record on disk holding the claims, the
|
|
4
|
+
span each rests on, the references and whether they resolved, the equations with
|
|
5
|
+
their dimensional verdicts, the figures, and the corpus hash the whole thing was
|
|
6
|
+
computed against. That is what makes guarantee 4 -- reproducibility -- more than
|
|
7
|
+
an intention: a record either replays or it does not.
|
|
8
|
+
|
|
9
|
+
The shape is deliberately serialisable to plain JSON. A record that can only be
|
|
10
|
+
read back by the version of the code that wrote it is not a durable artifact.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import asdict, dataclass, field
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from ..ingest.document import Anchor, Span
|
|
23
|
+
|
|
24
|
+
RECORD_SCHEMA_VERSION = 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class EvidenceItem:
|
|
29
|
+
"""One retrieved passage offered to whoever is drafting the answer."""
|
|
30
|
+
|
|
31
|
+
evidence_id: str
|
|
32
|
+
paper_id: str
|
|
33
|
+
section_path: str
|
|
34
|
+
quote: str
|
|
35
|
+
start: int
|
|
36
|
+
end: int
|
|
37
|
+
kind: str = "paragraph"
|
|
38
|
+
score: float = 0.0
|
|
39
|
+
|
|
40
|
+
def to_anchor(self) -> Anchor:
|
|
41
|
+
return Anchor(self.paper_id, self.section_path, Span(self.start, self.end), self.quote)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class EvidencePack:
|
|
46
|
+
"""What ``ask_grounded`` hands back: the raw material for an answer.
|
|
47
|
+
|
|
48
|
+
Deliberately not a draft. The drafting is done by whatever model is calling
|
|
49
|
+
us; our job is to supply passages that provably exist and then to judge what
|
|
50
|
+
comes back against them.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
question: str
|
|
54
|
+
items: list[EvidenceItem] = field(default_factory=list)
|
|
55
|
+
papers: dict[str, str] = field(default_factory=dict)
|
|
56
|
+
corpus_hash: str = ""
|
|
57
|
+
#: Instructions returned alongside the evidence, so a caller cannot claim
|
|
58
|
+
#: not to have known the rules it will be judged by.
|
|
59
|
+
contract: str = (
|
|
60
|
+
"Every sentence must cite one or more evidence_id values. A sentence "
|
|
61
|
+
"without one will be dropped. Do not paraphrase a quote into a stronger "
|
|
62
|
+
"claim than it supports. If the evidence does not answer the question, "
|
|
63
|
+
"say so rather than filling the gap."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def by_id(self, evidence_id: str) -> EvidenceItem | None:
|
|
67
|
+
return next((i for i in self.items if i.evidence_id == evidence_id), None)
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, Any]:
|
|
70
|
+
return {
|
|
71
|
+
"question": self.question,
|
|
72
|
+
"corpus_hash": self.corpus_hash,
|
|
73
|
+
"contract": self.contract,
|
|
74
|
+
"papers": self.papers,
|
|
75
|
+
"evidence": [asdict(i) for i in self.items],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class RecordedClaim:
|
|
81
|
+
"""A claim that survived the gate, with the spans that carry it."""
|
|
82
|
+
|
|
83
|
+
text: str
|
|
84
|
+
anchors: list[dict[str, Any]] = field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_anchors(cls, text: str, anchors: list[Anchor]) -> RecordedClaim:
|
|
88
|
+
return cls(
|
|
89
|
+
text=text,
|
|
90
|
+
anchors=[
|
|
91
|
+
{
|
|
92
|
+
"paper_id": a.paper_id,
|
|
93
|
+
"section_path": a.section_path,
|
|
94
|
+
"start": a.span.start,
|
|
95
|
+
"end": a.span.end,
|
|
96
|
+
"quote": a.quote,
|
|
97
|
+
}
|
|
98
|
+
for a in anchors
|
|
99
|
+
],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class RecordedEquation:
|
|
105
|
+
"""An equation shown in an answer, with its verdicts attached."""
|
|
106
|
+
|
|
107
|
+
latex: str
|
|
108
|
+
paper_id: str = ""
|
|
109
|
+
number: int | None = None
|
|
110
|
+
parsed: bool = False
|
|
111
|
+
dimensions: str = "unknown"
|
|
112
|
+
detail: str = ""
|
|
113
|
+
notes: list[str] = field(default_factory=list)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class AnswerRecord:
|
|
118
|
+
"""A complete, replayable answer."""
|
|
119
|
+
|
|
120
|
+
answer_id: str
|
|
121
|
+
question: str
|
|
122
|
+
claims: list[RecordedClaim] = field(default_factory=list)
|
|
123
|
+
equations: list[RecordedEquation] = field(default_factory=list)
|
|
124
|
+
figures: list[dict[str, Any]] = field(default_factory=list)
|
|
125
|
+
references: dict[str, bool] = field(default_factory=dict)
|
|
126
|
+
#: What the gate removed, and why. Always present, even when empty: a reader
|
|
127
|
+
#: needs to see that the check ran and found nothing, not merely find silence.
|
|
128
|
+
dropped: list[dict[str, str]] = field(default_factory=list)
|
|
129
|
+
corpus_hash: str = ""
|
|
130
|
+
created_at: str = ""
|
|
131
|
+
schema_version: int = RECORD_SCHEMA_VERSION
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def resolved_count(self) -> int:
|
|
135
|
+
return sum(1 for ok in self.references.values() if ok)
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def fully_resolved(self) -> bool:
|
|
139
|
+
"""Guarantee 1, restated as a property of the finished record."""
|
|
140
|
+
return all(self.references.values())
|
|
141
|
+
|
|
142
|
+
def summary(self) -> str:
|
|
143
|
+
parts = [f"{len(self.claims)} claims"]
|
|
144
|
+
if self.references:
|
|
145
|
+
parts.append(f"{self.resolved_count}/{len(self.references)} references resolved")
|
|
146
|
+
if self.equations:
|
|
147
|
+
checked = sum(1 for e in self.equations if e.dimensions == "consistent")
|
|
148
|
+
parts.append(f"{checked}/{len(self.equations)} equations dimensionally consistent")
|
|
149
|
+
if self.dropped:
|
|
150
|
+
parts.append(f"{len(self.dropped)} dropped")
|
|
151
|
+
return ", ".join(parts)
|
|
152
|
+
|
|
153
|
+
# ---- persistence ---------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def to_dict(self) -> dict[str, Any]:
|
|
156
|
+
return asdict(self)
|
|
157
|
+
|
|
158
|
+
def save(self, directory: Path) -> Path:
|
|
159
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
path = directory / f"{self.answer_id}.json"
|
|
161
|
+
path.write_text(
|
|
162
|
+
json.dumps(self.to_dict(), indent=2, sort_keys=True), encoding="utf-8"
|
|
163
|
+
)
|
|
164
|
+
return path
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def load(cls, path: Path) -> AnswerRecord:
|
|
168
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
169
|
+
version = data.get("schema_version", 0)
|
|
170
|
+
if version > RECORD_SCHEMA_VERSION:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"record schema v{version} is newer than this build understands "
|
|
173
|
+
f"(v{RECORD_SCHEMA_VERSION}); upgrade rather than reading it partially"
|
|
174
|
+
)
|
|
175
|
+
return cls(
|
|
176
|
+
answer_id=data["answer_id"],
|
|
177
|
+
question=data["question"],
|
|
178
|
+
claims=[RecordedClaim(**c) for c in data.get("claims", [])],
|
|
179
|
+
equations=[RecordedEquation(**e) for e in data.get("equations", [])],
|
|
180
|
+
figures=data.get("figures", []),
|
|
181
|
+
references=data.get("references", {}),
|
|
182
|
+
dropped=data.get("dropped", []),
|
|
183
|
+
corpus_hash=data.get("corpus_hash", ""),
|
|
184
|
+
created_at=data.get("created_at", ""),
|
|
185
|
+
schema_version=version,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def content_hash(self) -> str:
|
|
189
|
+
"""Hash over the record's substance, excluding the timestamp.
|
|
190
|
+
|
|
191
|
+
Two runs of the same question over the same corpus should hash the same,
|
|
192
|
+
so a changed hash means changed content rather than a changed clock.
|
|
193
|
+
"""
|
|
194
|
+
payload = self.to_dict()
|
|
195
|
+
payload.pop("created_at", None)
|
|
196
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
197
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def new_answer_id(question: str) -> str:
|
|
201
|
+
"""A short, stable-ish id: time-ordered, with a hash of the question."""
|
|
202
|
+
stamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
|
|
203
|
+
digest = hashlib.sha256(question.encode("utf-8")).hexdigest()[:6]
|
|
204
|
+
return f"ans_{stamp}_{digest}"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def utc_now() -> str:
|
|
208
|
+
return datetime.now(UTC).isoformat(timespec="seconds")
|