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 ADDED
@@ -0,0 +1,3 @@
1
+ """Mathen architecture knowledge base tools."""
2
+
3
+ __version__ = "0.0.1"
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"))