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
File without changes
@@ -0,0 +1,333 @@
1
+ """Run an offline decision and optionally write a self-contained HTML explanation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ import typer
11
+
12
+ from decision import contract
13
+ from decision.engine import decide as run_decision
14
+ from decision.engine import validate as validate_decision
15
+ from decision.registry import facet
16
+
17
+ from .vocabulary_cache import (
18
+ VocabularyInvalidError,
19
+ VocabularyMissingError,
20
+ load_cached_vocabulary,
21
+ )
22
+
23
+ EXIT_ERROR = 1
24
+
25
+
26
+ def _facet_lookup() -> contract.FacetLookup:
27
+ return facet
28
+
29
+
30
+ def _leaf_conditions(condition: Any, path: str):
31
+ if isinstance(condition, contract.AnyOf | contract.AllOf):
32
+ name = "any" if isinstance(condition, contract.AnyOf) else "all"
33
+ for index, child in enumerate(getattr(condition, name)):
34
+ yield from _leaf_conditions(child, f"{path}.{name}[{index}]")
35
+ elif isinstance(condition, contract.NotOf):
36
+ yield from _leaf_conditions(condition.not_, f"{path}.not")
37
+ else:
38
+ yield condition, path
39
+
40
+
41
+ def _vocabulary_warnings(spec: contract.Spec, vocabulary: dict[str, Any]) -> list[str]:
42
+ warnings: list[str] = []
43
+ published = {
44
+ row["id"]
45
+ for section in ("facets", "benchmarks", "domains")
46
+ for row in vocabulary.get(section, [])
47
+ }
48
+ providers = set(vocabulary.get("providers", {}))
49
+ domains = {row["id"] for row in vocabulary.get("domains", [])}
50
+
51
+ def add(path: str, value: str) -> None:
52
+ warning = (
53
+ f"{path}: {value!r} has no verified evidence in this snapshot; "
54
+ "results will be empty or may_qualify"
55
+ )
56
+ if warning not in warnings:
57
+ warnings.append(warning)
58
+
59
+ for domain in spec.capabilities or {}:
60
+ if domain not in domains:
61
+ add(f"capabilities.{domain}", domain)
62
+ if spec.task_type is not None and spec.task_type not in set(vocabulary.get("task_types", [])):
63
+ add("task_type", spec.task_type)
64
+ if isinstance(spec.profile, contract.InventoryProfile):
65
+ for index, offering in enumerate(spec.profile.offerings):
66
+ if offering.provider not in providers:
67
+ add(f"profile.offerings[{index}].provider", offering.provider)
68
+
69
+ conditions = list(enumerate(spec.where))
70
+ if isinstance(spec.profile, contract.InventoryProfile):
71
+ for index, condition in enumerate(spec.profile.rules):
72
+ for leaf, path in _leaf_conditions(condition, f"profile.rules[{index}]"):
73
+ conditions.append((path, leaf))
74
+ for index, condition in conditions:
75
+ path = f"where[{index}]" if isinstance(index, int) else index
76
+ leaves = (
77
+ _leaf_conditions(condition, path)
78
+ if isinstance(index, int)
79
+ else [(condition, path)]
80
+ )
81
+ for leaf, leaf_path in leaves:
82
+ facet_id = leaf.known if isinstance(leaf, contract.Known) else leaf.facet
83
+ if facet_id == "offering.provider" and not isinstance(leaf, contract.Known):
84
+ values = (
85
+ (leaf.in_ or leaf.not_in)
86
+ if isinstance(leaf, contract.InSet)
87
+ else [getattr(leaf, "value", None)]
88
+ )
89
+ for value in values or []:
90
+ if isinstance(value, str) and value not in providers:
91
+ add(leaf_path, value)
92
+ if facet_id not in published:
93
+ add(leaf_path, facet_id)
94
+ objective = spec.optimize
95
+ objective_facets = [objective.max, objective.min]
96
+ objective_facets += list(objective.weights or {}) + list(objective.pareto or [])
97
+ objective_facets += [step.facet for step in objective.lexicographic or []]
98
+ for facet_id in objective_facets:
99
+ if facet_id and facet_id.removeprefix("-") not in published:
100
+ add("optimize", facet_id.removeprefix("-"))
101
+ return warnings
102
+
103
+
104
+ def _issues(exc: contract.SpecError, vocabulary: dict[str, Any] | None) -> list[dict[str, Any]]:
105
+ known = set()
106
+ if vocabulary is not None:
107
+ known = {
108
+ row["id"]
109
+ for section in ("facets", "benchmarks", "domains")
110
+ for row in vocabulary.get(section, [])
111
+ }
112
+ rendered = []
113
+ for issue in exc.issues:
114
+ reason = issue.reason
115
+ if issue.field and "unknown facet" in reason:
116
+ close = difflib.get_close_matches(issue.field, sorted(known), n=3, cutoff=0.6)
117
+ extra = [item for item in close if repr(item) not in reason]
118
+ if extra:
119
+ reason += f"; cached vocabulary suggests {', '.join(repr(item) for item in extra)}"
120
+ rendered.append({
121
+ "path": issue.path, "condition": issue.condition,
122
+ "field": issue.field, "reason": reason,
123
+ })
124
+ return rendered
125
+
126
+
127
+ def _summary(spec: contract.Spec) -> str:
128
+ objective = spec.optimize
129
+ prefers = (len(objective.weights or {}) + len(objective.pareto or [])
130
+ + len(objective.lexicographic or [])
131
+ + int(objective.max is not None) + int(objective.min is not None))
132
+ return f"musts: {len(spec.where)}, prefers: {prefers}, snapshot: {spec.snapshot}"
133
+
134
+
135
+ def _fail(payload: dict[str, Any], lines: list[str], as_json: bool) -> None:
136
+ if as_json:
137
+ typer.echo(json.dumps(payload, indent=2), err=True)
138
+ else:
139
+ for line in lines:
140
+ typer.echo(line, err=True)
141
+ raise typer.Exit(EXIT_ERROR)
142
+
143
+
144
+ def _template(templates: list[dict[str, Any]], template_id: str, as_json: bool) -> dict[str, Any]:
145
+ for row in templates:
146
+ if row.get("id") == template_id:
147
+ spec = row.get("spec")
148
+ if isinstance(spec, dict):
149
+ return row
150
+ valid = sorted(str(row.get("id")) for row in templates if isinstance(row.get("id"), str))
151
+ message = f"unknown template {template_id!r}; valid ids: {', '.join(valid)}"
152
+ _fail(
153
+ {"contract_version": contract.CONTRACT_VERSION, "command": "decide",
154
+ "error": {"code": "unknown_template", "message": message, "valid_ids": valid}},
155
+ [f"error: {message}"],
156
+ as_json,
157
+ )
158
+
159
+
160
+ def _merge_template(template: dict[str, Any], raw: Any) -> dict[str, Any]:
161
+ fragment = dict(template["spec"])
162
+ if raw is None:
163
+ return fragment
164
+ if not isinstance(raw, dict):
165
+ return raw
166
+ template_where = fragment.get("where") or []
167
+ file_where = raw.get("where") or []
168
+ merged = fragment | raw
169
+ merged["where"] = [*template_where, *file_where]
170
+ return merged
171
+
172
+
173
+ def decide(
174
+ spec_path: Optional[Path] = typer.Argument( # noqa: UP045 - Typer reads the annotation
175
+ None, help="The optional spec, as YAML. Required without --template."
176
+ ),
177
+ template: Optional[str] = typer.Option( # noqa: UP045 - Typer reads the annotation
178
+ None, "--template", help="Start from a template in the cached vocabulary."
179
+ ),
180
+ explain: Optional[str] = typer.Option( # noqa: UP045 - Typer reads the annotation
181
+ None, "--explain", help="Override the spec's explain: none, summary or full."
182
+ ),
183
+ as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
184
+ snapshot_file: Optional[Path] = typer.Option( # noqa: UP045 - Typer annotation
185
+ None,
186
+ "--snapshot-file",
187
+ envvar="MODELSPEC_DECISION_SNAPSHOT",
188
+ help="Local decision snapshot (.gz).",
189
+ ),
190
+ html: Optional[Path] = typer.Option( # noqa: UP045 - Typer annotation
191
+ None, "--html", help="Write a self-contained HTML report."
192
+ ),
193
+ check: bool = typer.Option(
194
+ False, "--check", help="Validate against the cached vocabulary without deciding."
195
+ ),
196
+ ) -> None:
197
+ """Decide which model or offering fits a spec (the decision contract, v1)."""
198
+ base = {"contract_version": contract.CONTRACT_VERSION, "command": "decide"}
199
+ if spec_path is None and template is None:
200
+ _fail(
201
+ base | {"error": {
202
+ "code": "spec_required", "message": "pass SPEC.yaml or --template ID"
203
+ }},
204
+ ["error: pass SPEC.yaml or --template ID"],
205
+ as_json,
206
+ )
207
+ text = ""
208
+ if spec_path is not None:
209
+ try:
210
+ text = spec_path.read_text()
211
+ except OSError as exc:
212
+ _fail(
213
+ base | {"error": {"code": "unreadable", "message": str(exc)}},
214
+ [f"error: cannot read {spec_path}: {exc.strerror or exc}"],
215
+ as_json,
216
+ )
217
+
218
+ vocabulary: dict[str, Any] | None = None
219
+ if check or template is not None:
220
+ try:
221
+ vocabulary = load_cached_vocabulary()
222
+ except VocabularyMissingError as exc:
223
+ _fail(base | {"error": {"code": "snapshot_required", "message": str(exc)}},
224
+ [f"error: {exc}"], as_json)
225
+ except VocabularyInvalidError as exc:
226
+ _fail(base | {"error": {"code": "decision_failed", "message": str(exc)}},
227
+ [f"error: {exc}"], as_json)
228
+ templates = vocabulary.get("templates", []) if vocabulary is not None else []
229
+ if template is not None and not isinstance(templates, list):
230
+ message = "cached decision vocabulary has an invalid templates field"
231
+ _fail(base | {"error": {"code": "decision_failed", "message": message}},
232
+ [f"error: {message}"], as_json)
233
+ facets = _facet_lookup()
234
+ selected_template: dict[str, Any] | None = None
235
+ try:
236
+ raw = contract.load_yaml(text) if spec_path is not None else None
237
+ if template is not None:
238
+ selected_template = _template(templates, template, as_json)
239
+ raw = _merge_template(selected_template, raw)
240
+ if explain is not None and isinstance(raw, dict):
241
+ raw = raw | {"explain": explain}
242
+ spec = contract.parse_spec(raw, facets=facets)
243
+ except contract.SpecError as exc:
244
+ issues = _issues(exc, vocabulary)
245
+ _fail(
246
+ base | {"error": {"code": "invalid_spec", "issues": issues}},
247
+ ["error: invalid spec", *(f" {issue}" for issue in exc.issues)],
248
+ as_json,
249
+ )
250
+
251
+ base |= {"spec_hash": contract.spec_hash(spec), "explain": spec.explain}
252
+ template_warning = None
253
+ if selected_template is not None and not selected_template.get("available", True):
254
+ template_warning = selected_template.get("unavailable_reason") or (
255
+ "this template is unavailable against the cached snapshot"
256
+ )
257
+ typer.echo(f"warning: {template_warning}", err=True)
258
+ using_cached_snapshot = snapshot_file is None
259
+ if using_cached_snapshot:
260
+ from .snapshot import decision_snapshot_path
261
+
262
+ snapshot_file = decision_snapshot_path()
263
+ if not snapshot_file.is_file():
264
+ message = (
265
+ "no cached decision snapshot. Run `modelspec snapshot fetch`, "
266
+ "pass --snapshot-file, or set MODELSPEC_DECISION_SNAPSHOT"
267
+ )
268
+ _fail(
269
+ base | {"error": {"code": "snapshot_required", "message": message}},
270
+ [f"error: {message}"],
271
+ as_json,
272
+ )
273
+ if html is not None and spec.explain != "full":
274
+ _fail(
275
+ base
276
+ | {"error": {"code": "full_required", "message": "--html requires --explain full"}},
277
+ ["error: --html requires --explain full"],
278
+ as_json,
279
+ )
280
+ from decision.snapshot import load_snapshot
281
+
282
+ try:
283
+ index = (load_snapshot(snapshot_file, key=None, include_archive=True)
284
+ if using_cached_snapshot
285
+ else load_snapshot(snapshot_file, include_archive=True))
286
+ if check:
287
+ validate_decision(spec, index, facets=facets)
288
+ assert vocabulary is not None
289
+ warnings = _vocabulary_warnings(spec, vocabulary)
290
+ if template_warning is not None and template_warning not in warnings:
291
+ warnings.insert(0, template_warning)
292
+ cached_snapshot = vocabulary.get("snapshot")
293
+ if spec.snapshot != "latest" and spec.snapshot != cached_snapshot:
294
+ warnings.append(
295
+ f"spec pins {spec.snapshot}, but the cached vocabulary describes "
296
+ f"{cached_snapshot}"
297
+ )
298
+ for warning in warnings:
299
+ typer.echo(f"warning: {warning}", err=True)
300
+ summary = _summary(spec)
301
+ if as_json:
302
+ payload = base | {
303
+ "ok": True, "summary": summary, "snapshot": cached_snapshot,
304
+ "warnings": warnings,
305
+ }
306
+ typer.echo(json.dumps(payload, indent=2))
307
+ else:
308
+ typer.echo(f"ok: {summary}")
309
+ return
310
+ result = run_decision(spec, index, facets=facets)
311
+ if html is not None:
312
+ from decision.explain import render_html
313
+
314
+ html.write_text(render_html(result, index), encoding="utf-8")
315
+ except contract.SpecError as exc:
316
+ issues = _issues(exc, vocabulary)
317
+ _fail(
318
+ base | {"error": {"code": "decision_failed", "issues": issues}},
319
+ ["error: decision failed", *(f" {i['path']}: {i['reason']}" for i in issues)],
320
+ as_json,
321
+ )
322
+ except (OSError, ValueError, KeyError) as exc:
323
+ _fail(
324
+ base | {"error": {"code": "decision_failed", "message": str(exc)}},
325
+ [f"error: {exc}"],
326
+ as_json,
327
+ )
328
+ if as_json:
329
+ # The Worker's body byte for byte (api/worker/src/decide_service.serialise).
330
+ typer.echo(json.dumps(result.model_dump(mode="json"), ensure_ascii=False,
331
+ separators=(",", ":")))
332
+ else:
333
+ typer.echo(result.model_dump_json(indent=2))