modelspec-dev 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 (63) hide show
  1. api/__init__.py +0 -0
  2. api/class_fit.py +334 -0
  3. api/classes.py +557 -0
  4. api/ranking/__init__.py +12 -0
  5. api/ranking/engine.py +1943 -0
  6. cli/__init__.py +0 -0
  7. cli/modelspec/__init__.py +0 -0
  8. cli/modelspec/cli.py +1819 -0
  9. cli/modelspec/commands/__init__.py +0 -0
  10. cli/modelspec/decide_cmd.py +333 -0
  11. cli/modelspec/offline.py +623 -0
  12. cli/modelspec/snapshot.py +698 -0
  13. cli/modelspec/snapshot_build_cmd.py +49 -0
  14. cli/modelspec/verify_cmd.py +125 -0
  15. cli/modelspec/vocab_cmd.py +204 -0
  16. cli/modelspec/vocabulary_cache.py +54 -0
  17. decision/__init__.py +13 -0
  18. decision/capability.py +872 -0
  19. decision/computed.py +125 -0
  20. decision/contract.py +1575 -0
  21. decision/engine.py +238 -0
  22. decision/excluded.py +34 -0
  23. decision/explain.py +908 -0
  24. decision/filter.py +796 -0
  25. decision/model.py +438 -0
  26. decision/normalise.py +604 -0
  27. decision/optimise.py +320 -0
  28. decision/registry.py +717 -0
  29. decision/relax.py +132 -0
  30. decision/resolve.py +111 -0
  31. decision/schema.py +21 -0
  32. decision/snapshot.py +1483 -0
  33. decision/sources.py +544 -0
  34. decision/templates.py +134 -0
  35. decision/verify.py +1745 -0
  36. decision/vocabulary.py +433 -0
  37. modelspec_dev-0.1.0.dist-info/METADATA +101 -0
  38. modelspec_dev-0.1.0.dist-info/RECORD +63 -0
  39. modelspec_dev-0.1.0.dist-info/WHEEL +4 -0
  40. modelspec_dev-0.1.0.dist-info/entry_points.txt +2 -0
  41. modelspec_dev-0.1.0.dist-info/licenses/LICENSE +43 -0
  42. modelspec_dev-0.1.0.dist-info/licenses/LICENSE-DATA +428 -0
  43. pipeline/__init__.py +0 -0
  44. pipeline/class_export.py +172 -0
  45. pipeline/hardware.py +434 -0
  46. pipeline/hosts.py +247 -0
  47. pipeline/load.py +224 -0
  48. pipeline/ranking.py +551 -0
  49. registry/domains.yaml +130 -0
  50. registry/facets.yaml +888 -0
  51. registry/harnesses.yaml +79 -0
  52. registry/providers.yaml +354 -0
  53. registry/sources.yaml +3059 -0
  54. registry/templates.yaml +166 -0
  55. schema/__init__.py +0 -0
  56. schema/applicability.py +147 -0
  57. schema/benchmark.py +175 -0
  58. schema/benchmark_eligibility.py +304 -0
  59. schema/card.py +1463 -0
  60. schema/enrichment.py +162 -0
  61. schema/enums.py +327 -0
  62. schema/graph.py +406 -0
  63. schema/suppliers.py +72 -0
schema/graph.py ADDED
@@ -0,0 +1,406 @@
1
+ """FalkorDB graph schema — node types, edge types, and Cypher operations.
2
+
3
+ This module defines the complete ontology and provides functions to:
4
+ - Create indexes
5
+ - Ingest a ModelCard into the graph as nodes + edges
6
+ - Run common traversal queries
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass
13
+ from enum import Enum
14
+ from typing import Any
15
+
16
+ from .enums import DeviceClass
17
+
18
+
19
+ # ═══════════════════════════════════════════════════════════════
20
+ # Edge type registry
21
+ # ═══════════════════════════════════════════════════════════════
22
+
23
+ class EdgeType(str, Enum):
24
+ """All relationship types in the graph."""
25
+ # Factual (directly observed)
26
+ MADE_BY = "MADE_BY"
27
+ DERIVED_FROM = "DERIVED_FROM"
28
+ MERGED_FROM = "MERGED_FROM"
29
+ LICENSED_AS = "LICENSED_AS"
30
+ HAS_CAPABILITY = "HAS_CAPABILITY"
31
+ SCORED_ON = "SCORED_ON"
32
+ FITS_ON = "FITS_ON"
33
+ AVAILABLE_AS = "AVAILABLE_AS"
34
+ RUNS_ON = "RUNS_ON"
35
+ AVAILABLE_ON = "AVAILABLE_ON"
36
+ TAGGED_WITH = "TAGGED_WITH"
37
+ SUITED_FOR = "SUITED_FOR"
38
+ PUBLISHED_BY = "PUBLISHED_BY"
39
+ SUPPORTS_RUNTIME = "SUPPORTS_RUNTIME"
40
+ TESTS_CAPABILITY = "TESTS_CAPABILITY"
41
+ REQUIRED_BY = "REQUIRED_BY"
42
+ HOSTS = "HOSTS" # (:Host)-[:HOSTS]->(:Hardware), unified hosts only
43
+
44
+ # Derived (computed by ranking engine)
45
+ COMPETES_WITH = "COMPETES_WITH"
46
+ OUTPERFORMS = "OUTPERFORMS"
47
+ SIMILAR_TO = "SIMILAR_TO"
48
+ UPGRADE_PATH = "UPGRADE_PATH"
49
+ BEST_FOR = "BEST_FOR"
50
+ PARETO_OPTIMAL = "PARETO_OPTIMAL"
51
+
52
+ # Institutional (organization-specific overlay)
53
+ APPROVED_BY = "APPROVED_BY"
54
+ EXCLUDED_BY = "EXCLUDED_BY"
55
+ REVIEWED_BY = "REVIEWED_BY"
56
+ PREFERRED_BY = "PREFERRED_BY"
57
+
58
+
59
+ # ═══════════════════════════════════════════════════════════════
60
+ # Index definitions
61
+ # ═══════════════════════════════════════════════════════════════
62
+
63
+ INDEXES = [
64
+ "CREATE INDEX ON :Model(id)",
65
+ "CREATE INDEX ON :Model(model_type)",
66
+ "CREATE INDEX ON :Model(status)",
67
+ "CREATE INDEX ON :Model(origin_country)",
68
+ "CREATE INDEX ON :Model(open_weights)",
69
+ "CREATE INDEX ON :Model(total_parameters)",
70
+ "CREATE INDEX ON :Model(arena_elo_overall)",
71
+ "CREATE INDEX ON :Model(cost_input)",
72
+ "CREATE INDEX ON :Model(context_window)",
73
+ "CREATE INDEX ON :Model(release_date)",
74
+ "CREATE INDEX ON :Provider(id)",
75
+ "CREATE INDEX ON :Platform(id)",
76
+ "CREATE INDEX ON :Platform(category)",
77
+ "CREATE INDEX ON :Capability(id)",
78
+ "CREATE INDEX ON :Capability(category)",
79
+ "CREATE INDEX ON :Hardware(id)",
80
+ "CREATE INDEX ON :Hardware(memory_gb)",
81
+ # "which models fit datacentre hardware" is a class filter before it is a
82
+ # traversal, so the class is indexed like any other node grouping key.
83
+ "CREATE INDEX ON :Hardware(device_class)",
84
+ "CREATE INDEX ON :Host(id)",
85
+ "CREATE INDEX ON :Benchmark(id)",
86
+ "CREATE INDEX ON :Benchmark(category)",
87
+ "CREATE INDEX ON :License(id)",
88
+ "CREATE INDEX ON :UseCase(id)",
89
+ "CREATE INDEX ON :DownselectProfile(id)",
90
+ "CREATE INDEX ON :Runtime(id)",
91
+ "CREATE INDEX ON :Quantization(id)",
92
+ "CREATE INDEX ON :Tag(id)",
93
+ ]
94
+
95
+
96
+ def create_indexes(graph) -> None:
97
+ """Create all indexes on a FalkorDB graph instance."""
98
+ for idx in INDEXES:
99
+ try:
100
+ graph.query(idx)
101
+ except Exception:
102
+ pass # Index may already exist
103
+
104
+
105
+ # ═══════════════════════════════════════════════════════════════
106
+ # Ingestion: ModelCard → Graph nodes + edges
107
+ # ═══════════════════════════════════════════════════════════════
108
+
109
+ def device_class_of(hw_id: str, device_classes: Mapping[str, str] | None) -> str | None:
110
+ """The class of the device `hw_id` names, or None when nothing knows it.
111
+
112
+ `hardware/*.yaml` is the only source of a device class, and this module is
113
+ below the loader that reads it, so the caller passes the mapping in. A card
114
+ may key a hardware profile on anything, including the four legacy ids the
115
+ card schema still defaults to (`nvidia_5090_32gb` and friends), which name
116
+ no device record. Those get no class rather than a guessed one: a Hardware
117
+ node wrongly labelled `datacentre` is worse than one a class filter skips.
118
+
119
+ A value outside `DeviceClass` is a typo in a record, not a new class, and
120
+ is refused here so it cannot reach the export and split a group in two.
121
+ """
122
+ if not device_classes:
123
+ return None
124
+ value = device_classes.get(hw_id)
125
+ if value is None:
126
+ return None
127
+ return DeviceClass(value).value
128
+
129
+
130
+ def ingest_model_card(graph, card, *,
131
+ device_classes: Mapping[str, str] | None = None) -> dict[str, int]:
132
+ """Ingest a ModelCard into FalkorDB, creating/merging all nodes and edges.
133
+
134
+ `device_classes` maps a device id to its `DeviceClass` value (from
135
+ `pipeline.hardware.device_classes`). It is optional so the FalkorDB ingest
136
+ and the ranking CLI keep working without the hardware records; when it is
137
+ given, the Hardware nodes this derives carry `device_class` and can be
138
+ grouped by it.
139
+
140
+ Returns a dict with counts: {"nodes_created": N, "edges_created": M}
141
+ """
142
+ from .card import ModelCard
143
+
144
+ # Accept either a sink or a raw FalkorDB handle, so existing callers such
145
+ # as scripts/ingest_all.py keep working unchanged.
146
+ sink = graph if hasattr(graph, "node") else CypherSink(graph)
147
+
148
+ stats = {"nodes_created": 0, "edges_created": 0}
149
+ ident = card.identity
150
+ arch = card.architecture
151
+ lic = card.licensing
152
+
153
+ # ── 1. Upsert the :Model node ──────────────────────────
154
+ model_props = {
155
+ "id": ident.model_id,
156
+ "display_name": ident.display_name,
157
+ "model_type": ident.model_type.value if ident.model_type else None,
158
+ "status": ident.status.value,
159
+ "release_date": ident.release_date,
160
+ "family": ident.family,
161
+ "origin_country": lic.origin_country,
162
+ "open_weights": lic.open_weights,
163
+ "total_parameters": arch.total_parameters,
164
+ "active_parameters": arch.active_parameters,
165
+ "architecture_type": arch.type.value if arch.type else None,
166
+ "context_window": card.modalities.text.context_window,
167
+ "max_input": card.modalities.text.max_input_tokens,
168
+ "max_output": card.modalities.text.max_output_tokens,
169
+ "cost_input": card.cost.input,
170
+ "cost_output": card.cost.output,
171
+ "arena_elo_overall": card.benchmarks.scores.get("arena_elo_overall"),
172
+ "custom_score": card.downselect.custom_score,
173
+ "embedding_dimensions": arch.embedding_dimensions,
174
+ "reasoning": card.capabilities.reasoning.chain_of_thought,
175
+ "tool_call": card.capabilities.tool_use.function_calling,
176
+ "vision_input": card.modalities.vision.supported,
177
+ "multilingual": card.capabilities.language.multilingual,
178
+ "model_subtypes": ",".join(st.value for st in ident.model_subtypes) if ident.model_subtypes else None,
179
+ }
180
+
181
+ # Filter out None values for cleaner Cypher
182
+ props = {k: v for k, v in model_props.items() if v is not None}
183
+ sink.node("Model", "id", ident.model_id, props)
184
+ stats["nodes_created"] += 1
185
+
186
+ # ── 2. Upsert :Provider and :MADE_BY ───────────────────
187
+ if ident.provider:
188
+ sink.node("Provider", "id", ident.provider, {
189
+ "id": ident.provider,
190
+ "display_name": ident.provider_display or ident.provider,
191
+ "country": lic.origin_country,
192
+ })
193
+ sink.edge("Model", ident.model_id, "MADE_BY", "Provider", ident.provider)
194
+ stats["nodes_created"] += 1
195
+ stats["edges_created"] += 1
196
+
197
+ # ── 3. Upsert :License and :LICENSED_AS ────────────────
198
+ if lic.license_type:
199
+ license_id = lic.license_type.value
200
+ sink.node("License", "id", license_id, {
201
+ "id": license_id,
202
+ "name": license_id,
203
+ # A UsePermission since MODEL-77, not a bool: "restricted"
204
+ # is the answer for 169 cards and had nowhere to go before.
205
+ "commercial_ok": lic.commercial_use.value,
206
+ "defense_ok": lic.defense_use.value,
207
+ "government_ok": lic.government_use.value,
208
+ })
209
+ sink.edge("Model", ident.model_id, "LICENSED_AS", "License", license_id)
210
+ stats["edges_created"] += 1
211
+
212
+ # ── 4. Upsert :DERIVED_FROM (lineage) ──────────────────
213
+ if card.lineage.base_model:
214
+ sink.node("Model", "id", card.lineage.base_model, {
215
+ "id": card.lineage.base_model,
216
+ })
217
+ edge_props = {}
218
+ if card.lineage.base_model_relation:
219
+ edge_props["relation"] = card.lineage.base_model_relation.value
220
+ sink.edge("Model", ident.model_id, "DERIVED_FROM",
221
+ "Model", card.lineage.base_model, edge_props)
222
+ stats["edges_created"] += 1
223
+
224
+ # ── 5. Capabilities → :HAS_CAPABILITY edges ───────────
225
+ cap_map = _extract_capabilities(card.capabilities)
226
+ for cap_id, tier in cap_map.items():
227
+ category = cap_id.split(":")[0] if ":" in cap_id else "general"
228
+ sink.node("Capability", "id", cap_id, {
229
+ "id": cap_id,
230
+ "category": category,
231
+ "name": cap_id.split(":")[-1].replace("_", " ").title(),
232
+ })
233
+ sink.edge("Model", ident.model_id, "HAS_CAPABILITY",
234
+ "Capability", cap_id, {"tier": tier})
235
+ stats["edges_created"] += 1
236
+
237
+ # ── 6. Benchmarks → :SCORED_ON edges ───────────────────
238
+ for bench_id, value in card.benchmarks.scores.items():
239
+ if isinstance(value, (int, float)):
240
+ sink.node("Benchmark", "id", bench_id, {
241
+ "id": bench_id,
242
+ "name": bench_id.replace("_", " ").title(),
243
+ })
244
+ sink.edge("Model", ident.model_id, "SCORED_ON",
245
+ "Benchmark", bench_id, {
246
+ "value": float(value),
247
+ "date": card.benchmarks.benchmark_as_of,
248
+ })
249
+ stats["edges_created"] += 1
250
+
251
+ # ── 7. Hardware profiles → :FITS_ON edges ──────────────
252
+ for hw_id, profile in card.deployment.hardware_profiles.items():
253
+ if profile.fits:
254
+ hw_props = {"id": hw_id, "display_name": hw_id}
255
+ hw_class = device_class_of(hw_id, device_classes)
256
+ if hw_class is not None:
257
+ hw_props["device_class"] = hw_class
258
+ sink.node("Hardware", "id", hw_id, hw_props)
259
+ edge_props = {k: v for k, v in {
260
+ "quantization": profile.best_quant,
261
+ "vram_usage_gb": profile.vram_usage_gb or profile.ram_usage_gb,
262
+ "tokens_per_sec": profile.tokens_per_sec,
263
+ "ttft_ms": profile.ttft_ms,
264
+ "max_context_tokens": profile.max_context_at_quant,
265
+ "inference_engine": profile.inference_engine,
266
+ }.items() if v}
267
+ sink.edge("Model", ident.model_id, "FITS_ON",
268
+ "Hardware", hw_id, edge_props)
269
+ stats["edges_created"] += 1
270
+
271
+ # ── 8. Platform availability → :AVAILABLE_ON edges ─────
272
+ for field_name, field_value in card.availability:
273
+ if isinstance(field_value, PlatformEntry_type()) and field_value.available:
274
+ platform_id = field_name
275
+ sink.node("Platform", "id", platform_id, {
276
+ "id": platform_id,
277
+ "display_name": field_name.replace("_", " ").title(),
278
+ "url": field_value.url,
279
+ })
280
+ edge_props = {k: v for k, v in {
281
+ "model_id_on_platform": field_value.model_id,
282
+ "fine_tuning": field_value.fine_tuning,
283
+ "gated": field_value.gated,
284
+ "notes": field_value.notes,
285
+ }.items() if v}
286
+ sink.edge("Model", ident.model_id, "AVAILABLE_ON",
287
+ "Platform", platform_id, edge_props)
288
+ stats["edges_created"] += 1
289
+
290
+ # ── 9. Tags → :TAGGED_WITH edges ──────────────────────
291
+ for tag in ident.tags:
292
+ sink.node("Tag", "id", tag, {"id": tag})
293
+ sink.edge("Model", ident.model_id, "TAGGED_WITH", "Tag", tag)
294
+ stats["edges_created"] += 1
295
+
296
+ return stats
297
+
298
+
299
+ def PlatformEntry_type():
300
+ """Lazy import to avoid circular dependency."""
301
+ from .card import PlatformEntry
302
+ return PlatformEntry
303
+
304
+
305
+ # ═══════════════════════════════════════════════════════════════
306
+ # Helpers
307
+ # ═══════════════════════════════════════════════════════════════
308
+
309
+ class CypherSink:
310
+ """Writes the derived graph into FalkorDB, one MERGE at a time."""
311
+
312
+ def __init__(self, graph) -> None:
313
+ self.graph = graph
314
+
315
+ def node(self, label: str, key_field: str, key_value: str, props: dict[str, Any]) -> None:
316
+ extra_props = {k: v for k, v in props.items() if k != key_field}
317
+ if extra_props:
318
+ prop_str = ", ".join(f"n.{k} = ${k}" for k in extra_props)
319
+ query = f"MERGE (n:{label} {{{key_field}: ${key_field}}}) SET {prop_str}"
320
+ else:
321
+ query = f"MERGE (n:{label} {{{key_field}: ${key_field}}})"
322
+ self.graph.query(query, props)
323
+
324
+ def edge(self, from_label: str, from_id: str, edge_type: str,
325
+ to_label: str, to_id: str, props: dict[str, Any] | None = None) -> None:
326
+ if props:
327
+ prop_str = " {" + ", ".join(f"{k}: ${k}" for k in props) + "}"
328
+ else:
329
+ prop_str = ""
330
+ params = {"from_id": from_id, "to_id": to_id, **(props or {})}
331
+ query = (
332
+ f"MATCH (a:{from_label} {{id: $from_id}}) "
333
+ f"MATCH (b:{to_label} {{id: $to_id}}) "
334
+ f"MERGE (a)-[:{edge_type}{prop_str}]->(b)"
335
+ )
336
+ self.graph.query(query, params)
337
+
338
+
339
+ class CollectingSink:
340
+ """Accumulates the derived graph in memory, with no database.
341
+
342
+ This is what lets the build emit the graph as JSON. It reproduces MERGE
343
+ semantics: a node seen twice keeps the union of its properties, later
344
+ non-null values winning, so a model referenced as another model's base
345
+ before its own card is read still ends up complete.
346
+ """
347
+
348
+ def __init__(self) -> None:
349
+ self.nodes: dict[tuple[str, str], dict[str, Any]] = {}
350
+ self.edges: list[dict[str, Any]] = []
351
+
352
+ def node(self, label: str, key_field: str, key_value: str, props: dict[str, Any]) -> None:
353
+ existing = self.nodes.setdefault((label, key_value), {key_field: key_value})
354
+ for key, value in props.items():
355
+ if value is not None:
356
+ existing[key] = value
357
+
358
+ def edge(self, from_label: str, from_id: str, edge_type: str,
359
+ to_label: str, to_id: str, props: dict[str, Any] | None = None) -> None:
360
+ self.edges.append({
361
+ "type": edge_type,
362
+ "from": from_id,
363
+ "from_label": from_label,
364
+ "to": to_id,
365
+ "to_label": to_label,
366
+ **({"props": props} if props else {}),
367
+ })
368
+
369
+
370
+ def derive_graph(cards, device_classes: Mapping[str, str] | None = None) -> CollectingSink:
371
+ """Derive the whole graph from model cards, without touching a database.
372
+
373
+ `device_classes` is passed straight to `ingest_model_card`; see there.
374
+ """
375
+ sink = CollectingSink()
376
+ for card in cards:
377
+ ingest_model_card(sink, card, device_classes=device_classes)
378
+ return sink
379
+
380
+
381
+ def _extract_capabilities(caps) -> dict[str, str]:
382
+ """Extract capability IDs and tiers from the Capabilities model."""
383
+ result = {}
384
+
385
+ def _process_section(prefix: str, section):
386
+ for field_name, _ in section:
387
+ value = getattr(section, field_name)
388
+ if field_name == "overall" and value:
389
+ result[prefix] = value.value if hasattr(value, "value") else str(value)
390
+ elif isinstance(value, bool) and value:
391
+ cap_id = f"{prefix}:{field_name}"
392
+ # Boolean capabilities get tier from their section's overall
393
+ overall = getattr(section, "overall", None)
394
+ tier = overall.value if overall and hasattr(overall, "value") else "tier-2"
395
+ result[cap_id] = tier
396
+
397
+ _process_section("coding", caps.coding)
398
+ _process_section("reasoning", caps.reasoning)
399
+ _process_section("tool_use", caps.tool_use)
400
+ _process_section("language", caps.language)
401
+ _process_section("creative", caps.creative)
402
+ _process_section("safety", caps.safety_alignment)
403
+ _process_section("domain", caps.domain_specific)
404
+ _process_section("agent", caps.agent_capabilities)
405
+
406
+ return result
schema/suppliers.py ADDED
@@ -0,0 +1,72 @@
1
+ """Organisations ModelSpec buys from, and what a purchase costs us (MODEL-101).
2
+
3
+ ModelSpec pays TypeSafe for API access and runs **Jev — TypeSafe's own model —**
4
+ over ambiguous creator attributions (`scripts/attribution.py`). Then the
5
+ catalogue acquired a TypeSafe card, so the supplier is now also a subject.
6
+
7
+ Money flows *to* the vendor, so nothing in `neutrality_commitment()`
8
+ (`api/ranking/engine.py`) is contradicted: no referral fee, no paid placement,
9
+ no provider-paid visibility, and a purchase buys a supplier nothing in a
10
+ ranking. Two things follow anyway, and neither is a promise — both are
11
+ mechanisms:
12
+
13
+ 1. **The reader is told.** A card whose provider is named here carries the
14
+ disclosure on its page, derived from this table rather than authored per
15
+ card, so it cannot be left off the next one (`pipeline/render.py`).
16
+ 2. **A supplier's model never writes a field on that supplier's card.**
17
+ `scripts/attribution.py` refuses the judgment before it is asked, and
18
+ refuses a stored one before it is applied. `tests/test_attribution.py`
19
+ fails if either refusal is removed.
20
+
21
+ What is *not* refused: deterministic attribution. An id prefix that names
22
+ TypeSafe is code reading a string — no model call and no opinion — and it stays
23
+ allowed. The rule is about the judgment, and the judgment alone.
24
+
25
+ One table, two readers, so the org the page discloses and the org the guard
26
+ protects cannot drift apart.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Supplier:
36
+ """A vendor ModelSpec pays, that the catalogue also documents."""
37
+
38
+ #: The catalogue's provider slug: `models/<slug>/`.
39
+ slug: str
40
+ display: str
41
+ #: What we buy. Shown to a reader, so it is a sentence, not a label.
42
+ relationship: str
43
+ #: The rule that follows from it, in the same words on the page and here.
44
+ rule: str
45
+
46
+
47
+ SUPPLIERS: dict[str, Supplier] = {
48
+ "typesafe": Supplier(
49
+ slug="typesafe",
50
+ display="TypeSafe AI",
51
+ relationship=(
52
+ "ModelSpec is a paying TypeSafe customer. The catalogue's own pipeline sends "
53
+ "ambiguous creator attributions to Jev and pays per input token for the answer "
54
+ "(scripts/attribution.py, MODEL-82)."
55
+ ),
56
+ rule=(
57
+ "No field on a TypeSafe card may be written by a Jev judgment. TypeSafe listings "
58
+ "are attributed by code or by a person, or they are left unattributed; the "
59
+ "judgment is refused before it is asked."
60
+ ),
61
+ ),
62
+ }
63
+
64
+ #: Provider slugs under the rule. The guard and the page read the same set.
65
+ SUPPLIER_SLUGS: frozenset[str] = frozenset(SUPPLIERS)
66
+
67
+
68
+ def supplier_for(provider: str | None) -> Supplier | None:
69
+ """The supplier a card's `provider` names, or `None` for every other card."""
70
+ if not provider:
71
+ return None
72
+ return SUPPLIERS.get(str(provider).strip().lower())