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.
Files changed (57) hide show
  1. noether/__init__.py +11 -0
  2. noether/answer/__init__.py +0 -0
  3. noether/answer/pipeline.py +244 -0
  4. noether/answer/records.py +208 -0
  5. noether/bench.py +267 -0
  6. noether/cassettes.py +167 -0
  7. noether/cli.py +611 -0
  8. noether/cli_explain.py +149 -0
  9. noether/compute/__init__.py +0 -0
  10. noether/compute/normalise.py +414 -0
  11. noether/compute/plots.py +155 -0
  12. noether/compute/run.py +215 -0
  13. noether/compute/symbolic.py +313 -0
  14. noether/compute/units.py +280 -0
  15. noether/config.py +100 -0
  16. noether/draft.py +183 -0
  17. noether/explain/__init__.py +0 -0
  18. noether/explain/compose.py +244 -0
  19. noether/explain/concepts.py +143 -0
  20. noether/explain/library.py +496 -0
  21. noether/export.py +209 -0
  22. noether/index/__init__.py +0 -0
  23. noether/index/chunk.py +80 -0
  24. noether/index/embed.py +106 -0
  25. noether/index/retrieve.py +145 -0
  26. noether/index/store.py +384 -0
  27. noether/ingest/__init__.py +0 -0
  28. noether/ingest/bibliography.py +184 -0
  29. noether/ingest/document.py +195 -0
  30. noether/ingest/latex.py +439 -0
  31. noether/ingest/preprocess.py +347 -0
  32. noether/library.py +228 -0
  33. noether/maps.py +226 -0
  34. noether/mcp_server.py +434 -0
  35. noether/netclient.py +215 -0
  36. noether/provenance/__init__.py +0 -0
  37. noether/provenance/bundle.py +184 -0
  38. noether/sim/__init__.py +0 -0
  39. noether/sim/classical.py +271 -0
  40. noether/sim/engine.py +163 -0
  41. noether/sim/models.py +114 -0
  42. noether/sim/quantum.py +339 -0
  43. noether/sim/result.py +121 -0
  44. noether/sources/__init__.py +0 -0
  45. noether/sources/arxiv.py +279 -0
  46. noether/sources/registry.py +79 -0
  47. noether/sources/resolvers.py +270 -0
  48. noether/verify/__init__.py +0 -0
  49. noether/verify/citations.py +313 -0
  50. noether/verify/gate.py +221 -0
  51. noether/verify/matching.py +304 -0
  52. noether/watch.py +146 -0
  53. noether_physics-0.1.0.dist-info/METADATA +193 -0
  54. noether_physics-0.1.0.dist-info/RECORD +57 -0
  55. noether_physics-0.1.0.dist-info/WHEEL +4 -0
  56. noether_physics-0.1.0.dist-info/entry_points.txt +2 -0
  57. 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")