mathen 0.0.1__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.
- mathen/__init__.py +3 -0
- mathen/cards.py +426 -0
- mathen/cli.py +400 -0
- mathen/composition_solver.py +536 -0
- mathen/config.py +385 -0
- mathen/llm.py +293 -0
- mathen/modeling.py +238 -0
- mathen/relation_candidates.py +286 -0
- mathen/repositories.py +914 -0
- mathen/taxonomy.py +713 -0
- mathen-0.0.1.dist-info/METADATA +451 -0
- mathen-0.0.1.dist-info/RECORD +14 -0
- mathen-0.0.1.dist-info/WHEEL +4 -0
- mathen-0.0.1.dist-info/entry_points.txt +3 -0
mathen/__init__.py
ADDED
mathen/cards.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Loading and validating Architecture Cards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
REQUIRED_TOP_LEVEL = {
|
|
13
|
+
"id",
|
|
14
|
+
"name",
|
|
15
|
+
"summary",
|
|
16
|
+
"scope",
|
|
17
|
+
"features",
|
|
18
|
+
"mathematical_views",
|
|
19
|
+
"trade_offs",
|
|
20
|
+
"evidence",
|
|
21
|
+
}
|
|
22
|
+
CLAIM_TYPES = {"fact", "inference", "hypothesis", "analogy"}
|
|
23
|
+
CLAIM_STATUSES = {"proposed", "under-review", "accepted", "rejected", "deprecated"}
|
|
24
|
+
RELATION_TYPES = {"uses", "extends", "composes", "resembles", "influenced-by"}
|
|
25
|
+
FEATURE_RELATION_TYPES = {
|
|
26
|
+
"implements",
|
|
27
|
+
"refines",
|
|
28
|
+
"requires",
|
|
29
|
+
"supports",
|
|
30
|
+
"conflicts-with",
|
|
31
|
+
"composes",
|
|
32
|
+
"resembles",
|
|
33
|
+
}
|
|
34
|
+
ID_PATTERN = re.compile(r"^[a-z0-9-]+$")
|
|
35
|
+
EXPERIMENT_REQUIRED_FIELDS = (
|
|
36
|
+
"protocol",
|
|
37
|
+
"system_commit",
|
|
38
|
+
"environment",
|
|
39
|
+
"command",
|
|
40
|
+
"dataset",
|
|
41
|
+
"repetitions",
|
|
42
|
+
"metrics",
|
|
43
|
+
"artifacts",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CardValidationError(ValueError):
|
|
48
|
+
"""Raised when an Architecture Card violates project invariants."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_card(path: Path) -> dict[str, Any]:
|
|
52
|
+
"""Load one JSON card and perform semantic validation."""
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
56
|
+
except json.JSONDecodeError as error:
|
|
57
|
+
raise CardValidationError(f"{path}: invalid JSON: {error}") from error
|
|
58
|
+
if not isinstance(data, dict):
|
|
59
|
+
raise CardValidationError(f"{path}: card must be a JSON object")
|
|
60
|
+
validate_card(data, path)
|
|
61
|
+
return data
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def validate_card(card: dict[str, Any], path: Path | None = None) -> None:
|
|
65
|
+
"""Validate semantic rules not covered by the JSON Schema dependency."""
|
|
66
|
+
|
|
67
|
+
label = str(path) if path else card.get("id", "card")
|
|
68
|
+
missing = REQUIRED_TOP_LEVEL - card.keys()
|
|
69
|
+
if missing:
|
|
70
|
+
raise CardValidationError(f"{label}: missing fields: {sorted(missing)}")
|
|
71
|
+
if not isinstance(card["id"], str) or not card["id"].islower():
|
|
72
|
+
raise CardValidationError(f"{label}: id must be lowercase")
|
|
73
|
+
|
|
74
|
+
evidence = card["evidence"]
|
|
75
|
+
if not isinstance(evidence, list) or not evidence:
|
|
76
|
+
raise CardValidationError(f"{label}: evidence must be a non-empty list")
|
|
77
|
+
for index, item in enumerate(evidence):
|
|
78
|
+
_validate_evidence(label, index, item)
|
|
79
|
+
evidence_ids = {item.get("id") for item in evidence if isinstance(item, dict)}
|
|
80
|
+
|
|
81
|
+
feature_ids: set[str] = set()
|
|
82
|
+
for feature in card["features"]:
|
|
83
|
+
if not isinstance(feature, dict):
|
|
84
|
+
raise CardValidationError(f"{label}: every feature must be an object")
|
|
85
|
+
feature_id = feature.get("id")
|
|
86
|
+
if feature_id in feature_ids:
|
|
87
|
+
raise CardValidationError(f"{label}: duplicate feature id: {feature_id}")
|
|
88
|
+
feature_ids.add(feature_id)
|
|
89
|
+
if feature.get("claim_type") not in CLAIM_TYPES:
|
|
90
|
+
raise CardValidationError(
|
|
91
|
+
f"{label}: invalid feature claim type: {feature_id}"
|
|
92
|
+
)
|
|
93
|
+
_check_optional_claim_status(label, feature_id, feature.get("claim_status"))
|
|
94
|
+
_check_evidence_ids(
|
|
95
|
+
label, feature_id, feature.get("evidence_ids"), evidence_ids
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
for view in card["mathematical_views"]:
|
|
99
|
+
if view.get("claim_type") not in CLAIM_TYPES:
|
|
100
|
+
raise CardValidationError(f"{label}: invalid mathematical view claim type")
|
|
101
|
+
_check_optional_claim_status(
|
|
102
|
+
label, view.get("model", "view"), view.get("claim_status")
|
|
103
|
+
)
|
|
104
|
+
_check_evidence_ids(
|
|
105
|
+
label, view.get("model", "view"), view.get("evidence_ids"), evidence_ids
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
for trade_off in card["trade_offs"]:
|
|
109
|
+
if trade_off.get("feature_id") not in feature_ids:
|
|
110
|
+
raise CardValidationError(
|
|
111
|
+
f"{label}: trade-off references unknown feature: "
|
|
112
|
+
f"{trade_off.get('feature_id')}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if "temporal_context" in card:
|
|
116
|
+
temporal = _require_object(label, "temporal_context", card["temporal_context"])
|
|
117
|
+
_check_claim_status(label, "temporal_context", temporal.get("claim_status"))
|
|
118
|
+
_check_evidence_ids(
|
|
119
|
+
label, "temporal_context", temporal.get("evidence_ids"), evidence_ids
|
|
120
|
+
)
|
|
121
|
+
for field in ("supersedes", "superseded_by"):
|
|
122
|
+
references = temporal.get(field, [])
|
|
123
|
+
if not isinstance(references, list) or not all(
|
|
124
|
+
isinstance(reference, str) and reference for reference in references
|
|
125
|
+
):
|
|
126
|
+
raise CardValidationError(f"{label}: {field} must be a list of ids")
|
|
127
|
+
|
|
128
|
+
for index, relation in enumerate(card.get("relations", [])):
|
|
129
|
+
relation_label = f"relation[{index}]"
|
|
130
|
+
relation = _require_object(label, relation_label, relation)
|
|
131
|
+
if relation.get("type") not in RELATION_TYPES:
|
|
132
|
+
raise CardValidationError(f"{label}: invalid {relation_label} type")
|
|
133
|
+
if not isinstance(relation.get("target"), str) or not relation["target"]:
|
|
134
|
+
raise CardValidationError(f"{label}: {relation_label} needs a target")
|
|
135
|
+
if relation.get("claim_type") not in CLAIM_TYPES:
|
|
136
|
+
raise CardValidationError(f"{label}: invalid {relation_label} claim type")
|
|
137
|
+
_check_claim_status(label, relation_label, relation.get("claim_status"))
|
|
138
|
+
_check_evidence_ids(
|
|
139
|
+
label, relation_label, relation.get("evidence_ids"), evidence_ids
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if "composition" in card:
|
|
143
|
+
_validate_composition(label, card["composition"], evidence_ids)
|
|
144
|
+
|
|
145
|
+
if "feature_relations" in card:
|
|
146
|
+
_validate_feature_relations(
|
|
147
|
+
label, card["feature_relations"], feature_ids, evidence_ids
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def validate_card_collection(
|
|
152
|
+
cards: Iterable[dict[str, Any]], require_feature_relation_targets: bool = True
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Validate cross-card references after each card passes local validation."""
|
|
155
|
+
|
|
156
|
+
cards = list(cards)
|
|
157
|
+
card_by_id: dict[str, dict[str, Any]] = {}
|
|
158
|
+
for card in cards:
|
|
159
|
+
validate_card(card)
|
|
160
|
+
card_id = card.get("id")
|
|
161
|
+
if isinstance(card_id, str):
|
|
162
|
+
if card_id in card_by_id:
|
|
163
|
+
raise CardValidationError(f"duplicate card id: {card_id}")
|
|
164
|
+
card_by_id[card_id] = card
|
|
165
|
+
|
|
166
|
+
if not require_feature_relation_targets:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
for card in cards:
|
|
170
|
+
source_id = card.get("id", "card")
|
|
171
|
+
for index, relation in enumerate(card.get("feature_relations", [])):
|
|
172
|
+
target_system_id = relation["target_system_id"]
|
|
173
|
+
target_card = card_by_id.get(target_system_id)
|
|
174
|
+
if target_card is None:
|
|
175
|
+
raise CardValidationError(
|
|
176
|
+
f"{source_id}: feature_relation[{index}] references unknown "
|
|
177
|
+
f"target system: {target_system_id}"
|
|
178
|
+
)
|
|
179
|
+
target_features = {
|
|
180
|
+
feature.get("id")
|
|
181
|
+
for feature in target_card.get("features", [])
|
|
182
|
+
if isinstance(feature, dict)
|
|
183
|
+
}
|
|
184
|
+
if relation["target_feature_id"] not in target_features:
|
|
185
|
+
raise CardValidationError(
|
|
186
|
+
f"{source_id}: feature_relation[{index}] references unknown "
|
|
187
|
+
"target feature: "
|
|
188
|
+
f"{target_system_id}:{relation['target_feature_id']}"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _validate_feature_relations(
|
|
193
|
+
label: str,
|
|
194
|
+
relations: Any,
|
|
195
|
+
feature_ids: set[str],
|
|
196
|
+
evidence_ids: set[str | None],
|
|
197
|
+
) -> None:
|
|
198
|
+
if not isinstance(relations, list):
|
|
199
|
+
raise CardValidationError(f"{label}: feature_relations must be a list")
|
|
200
|
+
|
|
201
|
+
seen: set[tuple[str, str, str, str]] = set()
|
|
202
|
+
required = {
|
|
203
|
+
"source_feature_id",
|
|
204
|
+
"target_system_id",
|
|
205
|
+
"target_feature_id",
|
|
206
|
+
"type",
|
|
207
|
+
"claim_type",
|
|
208
|
+
"claim_status",
|
|
209
|
+
"evidence_ids",
|
|
210
|
+
}
|
|
211
|
+
allowed = required | {"notes"}
|
|
212
|
+
for index, relation in enumerate(relations):
|
|
213
|
+
relation_label = f"feature_relation[{index}]"
|
|
214
|
+
relation = _require_object(label, relation_label, relation)
|
|
215
|
+
missing = required - relation.keys()
|
|
216
|
+
if missing:
|
|
217
|
+
raise CardValidationError(
|
|
218
|
+
f"{label}: {relation_label} missing fields: {sorted(missing)}"
|
|
219
|
+
)
|
|
220
|
+
unexpected = relation.keys() - allowed
|
|
221
|
+
if unexpected:
|
|
222
|
+
raise CardValidationError(
|
|
223
|
+
f"{label}: {relation_label} has unexpected fields: {sorted(unexpected)}"
|
|
224
|
+
)
|
|
225
|
+
source_feature_id = relation["source_feature_id"]
|
|
226
|
+
if not isinstance(source_feature_id, str) or not ID_PATTERN.fullmatch(
|
|
227
|
+
source_feature_id
|
|
228
|
+
):
|
|
229
|
+
raise CardValidationError(
|
|
230
|
+
f"{label}: invalid {relation_label} source feature id"
|
|
231
|
+
)
|
|
232
|
+
if source_feature_id not in feature_ids:
|
|
233
|
+
raise CardValidationError(
|
|
234
|
+
f"{label}: {relation_label} references unknown source feature: "
|
|
235
|
+
f"{source_feature_id}"
|
|
236
|
+
)
|
|
237
|
+
target_system_id = relation["target_system_id"]
|
|
238
|
+
target_feature_id = relation["target_feature_id"]
|
|
239
|
+
for field, value in (
|
|
240
|
+
("target system", target_system_id),
|
|
241
|
+
("target feature", target_feature_id),
|
|
242
|
+
):
|
|
243
|
+
if not isinstance(value, str) or not ID_PATTERN.fullmatch(value):
|
|
244
|
+
raise CardValidationError(
|
|
245
|
+
f"{label}: invalid {relation_label} {field} id"
|
|
246
|
+
)
|
|
247
|
+
if relation["type"] not in FEATURE_RELATION_TYPES:
|
|
248
|
+
raise CardValidationError(f"{label}: invalid {relation_label} type")
|
|
249
|
+
if relation["claim_type"] not in CLAIM_TYPES:
|
|
250
|
+
raise CardValidationError(f"{label}: invalid {relation_label} claim type")
|
|
251
|
+
_check_claim_status(label, relation_label, relation["claim_status"])
|
|
252
|
+
if "notes" in relation and not isinstance(relation["notes"], str):
|
|
253
|
+
raise CardValidationError(
|
|
254
|
+
f"{label}: {relation_label} notes must be a string"
|
|
255
|
+
)
|
|
256
|
+
relation_evidence_ids = relation["evidence_ids"]
|
|
257
|
+
if not isinstance(relation_evidence_ids, list) or not relation_evidence_ids:
|
|
258
|
+
raise CardValidationError(f"{label}: {relation_label} needs evidence_ids")
|
|
259
|
+
if not all(
|
|
260
|
+
isinstance(evidence_id, str) for evidence_id in relation_evidence_ids
|
|
261
|
+
):
|
|
262
|
+
raise CardValidationError(
|
|
263
|
+
f"{label}: {relation_label} evidence_ids must contain strings"
|
|
264
|
+
)
|
|
265
|
+
_check_evidence_ids(label, relation_label, relation_evidence_ids, evidence_ids)
|
|
266
|
+
identity = (
|
|
267
|
+
source_feature_id,
|
|
268
|
+
target_system_id,
|
|
269
|
+
target_feature_id,
|
|
270
|
+
relation["type"],
|
|
271
|
+
)
|
|
272
|
+
if identity in seen:
|
|
273
|
+
raise CardValidationError(f"{label}: duplicate {relation_label} identity")
|
|
274
|
+
seen.add(identity)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _validate_composition(
|
|
278
|
+
label: str, composition: Any, evidence_ids: set[str | None]
|
|
279
|
+
) -> None:
|
|
280
|
+
composition = _require_object(label, "composition", composition)
|
|
281
|
+
inputs = composition.get("inputs")
|
|
282
|
+
if not isinstance(inputs, list) or not inputs:
|
|
283
|
+
raise CardValidationError(f"{label}: composition needs inputs")
|
|
284
|
+
input_ids: set[str] = set()
|
|
285
|
+
for index, item in enumerate(inputs):
|
|
286
|
+
input_label = f"composition.inputs[{index}]"
|
|
287
|
+
item = _require_object(label, input_label, item)
|
|
288
|
+
system_id = item.get("system_id")
|
|
289
|
+
if not isinstance(system_id, str) or not system_id:
|
|
290
|
+
raise CardValidationError(f"{label}: {input_label} needs a system_id")
|
|
291
|
+
if system_id in input_ids:
|
|
292
|
+
raise CardValidationError(
|
|
293
|
+
f"{label}: duplicate composition input: {system_id}"
|
|
294
|
+
)
|
|
295
|
+
input_ids.add(system_id)
|
|
296
|
+
if not isinstance(item.get("role"), str) or not item["role"]:
|
|
297
|
+
raise CardValidationError(f"{label}: {input_label} needs a role")
|
|
298
|
+
_check_claim_status(label, input_label, item.get("claim_status"))
|
|
299
|
+
_check_evidence_ids(label, input_label, item.get("evidence_ids"), evidence_ids)
|
|
300
|
+
|
|
301
|
+
for field in (
|
|
302
|
+
"constraints",
|
|
303
|
+
"preserved_properties",
|
|
304
|
+
"conflicts",
|
|
305
|
+
"validation_gaps",
|
|
306
|
+
):
|
|
307
|
+
if field not in composition:
|
|
308
|
+
continue
|
|
309
|
+
claims = composition.get(field)
|
|
310
|
+
if not isinstance(claims, list):
|
|
311
|
+
raise CardValidationError(f"{label}: composition.{field} must be a list")
|
|
312
|
+
for index, claim in enumerate(claims):
|
|
313
|
+
claim_label = f"composition.{field}[{index}]"
|
|
314
|
+
claim = _require_object(label, claim_label, claim)
|
|
315
|
+
if (
|
|
316
|
+
not isinstance(claim.get("description"), str)
|
|
317
|
+
or not claim["description"]
|
|
318
|
+
):
|
|
319
|
+
raise CardValidationError(f"{label}: {claim_label} needs a description")
|
|
320
|
+
if claim.get("claim_type") not in CLAIM_TYPES:
|
|
321
|
+
raise CardValidationError(f"{label}: invalid {claim_label} claim type")
|
|
322
|
+
_check_claim_status(label, claim_label, claim.get("claim_status"))
|
|
323
|
+
_check_evidence_ids(
|
|
324
|
+
label, claim_label, claim.get("evidence_ids"), evidence_ids
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
_check_claim_status(label, "composition", composition.get("claim_status"))
|
|
328
|
+
_check_evidence_ids(
|
|
329
|
+
label, "composition", composition.get("evidence_ids"), evidence_ids
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _require_object(label: str, field: str, value: Any) -> dict[str, Any]:
|
|
334
|
+
if not isinstance(value, dict):
|
|
335
|
+
raise CardValidationError(f"{label}: {field} must be an object")
|
|
336
|
+
return value
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _validate_evidence(label: str, index: int, evidence: Any) -> None:
|
|
340
|
+
if not isinstance(evidence, dict):
|
|
341
|
+
raise CardValidationError(f"{label}: evidence[{index}] must be an object")
|
|
342
|
+
if evidence.get("kind") != "experiment":
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
evidence_label = f"evidence[{index}]"
|
|
346
|
+
missing = [field for field in EXPERIMENT_REQUIRED_FIELDS if field not in evidence]
|
|
347
|
+
if missing:
|
|
348
|
+
raise CardValidationError(
|
|
349
|
+
f"{label}: {evidence_label} missing experiment fields: {missing}"
|
|
350
|
+
)
|
|
351
|
+
for field in ("protocol", "system_commit", "command"):
|
|
352
|
+
if not isinstance(evidence[field], str) or not evidence[field].strip():
|
|
353
|
+
raise CardValidationError(
|
|
354
|
+
f"{label}: {evidence_label}.{field} must be a non-empty string"
|
|
355
|
+
)
|
|
356
|
+
for field in ("environment", "dataset"):
|
|
357
|
+
value = evidence[field]
|
|
358
|
+
if not (isinstance(value, str) and value.strip() or isinstance(value, dict)):
|
|
359
|
+
raise CardValidationError(
|
|
360
|
+
f"{label}: {evidence_label}.{field} must be a string or object"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
repetitions = evidence["repetitions"]
|
|
364
|
+
if isinstance(repetitions, bool) or not isinstance(repetitions, int):
|
|
365
|
+
raise CardValidationError(
|
|
366
|
+
f"{label}: {evidence_label}.repetitions must be a positive integer"
|
|
367
|
+
)
|
|
368
|
+
if repetitions < 1:
|
|
369
|
+
raise CardValidationError(
|
|
370
|
+
f"{label}: {evidence_label}.repetitions must be a positive integer"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
metrics = evidence["metrics"]
|
|
374
|
+
if not isinstance(metrics, dict):
|
|
375
|
+
raise CardValidationError(
|
|
376
|
+
f"{label}: {evidence_label}.metrics must be an object"
|
|
377
|
+
)
|
|
378
|
+
for name, value in metrics.items():
|
|
379
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
380
|
+
raise CardValidationError(
|
|
381
|
+
f"{label}: {evidence_label}.metrics.{name} must be a number"
|
|
382
|
+
)
|
|
383
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
384
|
+
raise CardValidationError(
|
|
385
|
+
f"{label}: {evidence_label}.metrics.{name} must be finite"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
artifacts = evidence["artifacts"]
|
|
389
|
+
if not isinstance(artifacts, list):
|
|
390
|
+
raise CardValidationError(f"{label}: {evidence_label}.artifacts must be a list")
|
|
391
|
+
for artifact in artifacts:
|
|
392
|
+
if not (
|
|
393
|
+
isinstance(artifact, dict) or isinstance(artifact, str) and artifact.strip()
|
|
394
|
+
):
|
|
395
|
+
raise CardValidationError(
|
|
396
|
+
f"{label}: {evidence_label}.artifacts entries must be strings "
|
|
397
|
+
"or objects"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _check_claim_status(label: str, item_id: str, status: Any) -> None:
|
|
402
|
+
if status not in CLAIM_STATUSES:
|
|
403
|
+
raise CardValidationError(f"{label}: invalid {item_id} claim status")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _check_optional_claim_status(label: str, item_id: str, status: Any) -> None:
|
|
407
|
+
if status is not None:
|
|
408
|
+
_check_claim_status(label, item_id, status)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _check_evidence_ids(
|
|
412
|
+
label: str, item_id: str, references: Any, evidence_ids: set[str | None]
|
|
413
|
+
) -> None:
|
|
414
|
+
if not isinstance(references, list) or not references:
|
|
415
|
+
raise CardValidationError(f"{label}: {item_id} needs evidence_ids")
|
|
416
|
+
if len(references) != len(set(references)):
|
|
417
|
+
raise CardValidationError(f"{label}: {item_id} has duplicate evidence ids")
|
|
418
|
+
unknown = set(references) - evidence_ids
|
|
419
|
+
if unknown:
|
|
420
|
+
raise CardValidationError(f"{label}: unknown evidence ids: {sorted(unknown)}")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def discover_cards(root: Path) -> list[Path]:
|
|
424
|
+
"""Return cards in deterministic order."""
|
|
425
|
+
|
|
426
|
+
return sorted(root.glob("*.json"))
|