Spaces:
Sleeping
Sleeping
| """uofa interrogate β measure a surrogate (and `init` to set up the inputs). | |
| Two surfaces, deliberately different in kind (Addendum A14): | |
| - `uofa interrogate --adapter β¦ --benchmark β¦ --reference β¦ --scope β¦ -o pkg` | |
| MEASURES and emits a signed evidence bundle + an at-a-glance comparison. This | |
| command never judges: no pass/fail, no threshold flag, no chaining into the | |
| rule engine (the firewall, Β§8 / AGENTS.md Β§12). | |
| - `uofa interrogate init` is the GUIDED setup wizard. By default it is | |
| interactive because the questions are about the *model* (what are its inputs | |
| in physical terms, what is the valid envelope). `--yes` runs it | |
| non-interactively for scripts/CI/containers: the engineer hands a pre-written | |
| `--scope` file instead of answering prompts. Either way it never silently | |
| defaults scope, never fabricates reference values, and smoke-tests the | |
| generated adapter before declaring success (A14.1/A14.3) β `--yes` removes the | |
| interactivity, not the rigor. | |
| Heavy deps (numpy + the user's adapter framework) are imported lazily via the | |
| `[interrogate]` extra. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from pathlib import Path | |
| from uofa_cli import paths | |
| from uofa_cli.output import error, info, result_line, step_header | |
| HELP = "measure a surrogate and emit a signed evidence bundle (or `init` to set up)" | |
| SURROGATE_TYPES = ["ROM", "PINN", "operator-learning", "data-driven-emulator", "ML-closure"] | |
| def add_arguments(parser): | |
| # Measure-path flags live on the parent and are OPTIONAL at the argparse | |
| # level (validated in run), so they don't collide with the `init` subcommand. | |
| parser.add_argument("--adapter", help="ModelAdapter ref: 'pkg.module.ClassName' or '/path/file.py:ClassName'") | |
| parser.add_argument("--benchmark", type=Path, help="benchmark inputs (.npz/.json)") | |
| parser.add_argument("--reference", type=Path, help="reference outputs (.npz/.json) β supplied, never generated") | |
| parser.add_argument("--scope", type=Path, help="declared-scope config (.json/.toml)") | |
| parser.add_argument("--output", "-o", type=Path, help="output path for the signed evidence bundle (.json)") | |
| parser.add_argument("--key", "-k", type=Path, help="ed25519 SIP measurement key (auto-detected from project keys/ if omitted)") | |
| parser.add_argument("--seed", type=int, default=None, help="seed recorded in measurement provenance") | |
| # NOTE: deliberately NO --check / --decision / --threshold flag (the firewall). | |
| sub = parser.add_subparsers(dest="interrogate_cmd") | |
| init_p = sub.add_parser("init", help="guided setup: detect the model, generate adapter + scope + smoke-test") | |
| init_p.add_argument("--model", type=Path, help="path to the model file/dir to inspect") | |
| init_p.add_argument("--docs", type=Path, help="model card / training report the scope values came from (provenance)") | |
| init_p.add_argument("--benchmark", type=Path, help="benchmark inputs, used for the adapter smoke test") | |
| init_p.add_argument("--reference", type=Path, help="reference outputs (supplied; never generated)") | |
| init_p.add_argument("--out-dir", type=Path, default=Path("."), help="where to write the generated adapter + scope") | |
| # Non-interactive setup (scripts / CI / containers). --yes does NOT relax the | |
| # A14 invariants: scope still carries per-field provenance and is never | |
| # silently defaulted β the engineer supplies it as a file instead of typing it. | |
| init_p.add_argument("--yes", "--non-interactive", dest="yes", action="store_true", | |
| help="non-interactive: adopt a pre-written --scope file instead of prompting") | |
| init_p.add_argument("--scope", type=Path, help="(--yes) pre-written scope config (.json/.toml) adopted verbatim") | |
| init_p.add_argument("--output-names", help="(--yes) comma-separated QoI names the adapter returns") | |
| init_p.add_argument("--input-names", help="(--yes) comma-separated input names (overrides names derived from the scope envelope)") | |
| def run(args) -> int: | |
| if getattr(args, "interrogate_cmd", None) == "init": | |
| return _run_init(args) | |
| return _run_measure(args) | |
| # ββ Measure path βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _load_scope(path: Path) -> dict: | |
| if not path.is_file(): | |
| raise FileNotFoundError(f"Scope config not found: {path}") | |
| text = path.read_text(encoding="utf-8") | |
| if path.suffix.lower() == ".toml": | |
| try: | |
| import tomllib | |
| except ModuleNotFoundError: # py3.10 | |
| import tomli as tomllib # type: ignore[no-redef] | |
| return tomllib.loads(text) | |
| return json.loads(text) | |
| def _resolve_key(args) -> Path | None: | |
| if getattr(args, "key", None): | |
| return Path(args.key) | |
| project_root = paths.find_project_root() | |
| if project_root: | |
| keys_dir = project_root / "keys" | |
| if keys_dir.is_dir(): | |
| for candidate in sorted(keys_dir.glob("*.key")): | |
| return candidate | |
| return None | |
| def _run_measure(args) -> int: | |
| missing = [f for f in ("adapter", "benchmark", "reference", "scope", "output") | |
| if getattr(args, f, None) is None] | |
| if missing: | |
| error("missing required option(s) for measurement: " | |
| + ", ".join(f"--{m}" for m in missing) | |
| + " (or run `uofa interrogate init` for guided setup)") | |
| return 2 | |
| from uofa_cli.interrogate import run_interrogation | |
| from uofa_cli.interrogate.forbidden import find_forbidden_in_measurement_region | |
| from uofa_cli.interrogate.comparison import render_comparison | |
| scope = _load_scope(Path(args.scope)) | |
| key_path = _resolve_key(args) | |
| step_header(f"Interrogating surrogate via {args.adapter}") | |
| info(f"benchmark: {args.benchmark}") | |
| info(f"reference: {args.reference}") | |
| result = run_interrogation( | |
| adapter_ref=args.adapter, benchmark_path=args.benchmark, reference_path=args.reference, | |
| scope=scope, output_path=args.output, key_path=key_path, seed=getattr(args, "seed", None), | |
| ) | |
| # Firewall, defense in depth (signature-scoped): no forbidden decision | |
| # content in the measurement region of the emitted bundle. | |
| leaks = list(find_forbidden_in_measurement_region(result["bundle"])) | |
| if leaks: | |
| error("Firewall violation β forbidden field(s) in the measurement region: " | |
| + ", ".join(sorted({token for _, token in leaks}))) | |
| return 1 | |
| residual_count = len(result["bundle"]["measurements"]["referenceResiduals"]) | |
| result_line("Wrote evidence bundle", True, str(result["output_path"])) | |
| info(f"measured {residual_count} reference-residual QoI(s)") | |
| if result["signed"]: | |
| info(f"signed: ed25519 (sha256:{result['hash'][:12]})") | |
| else: | |
| info("bundle is unsigned (no signing key provided)") | |
| # At-a-glance comparison (A3). Measurements only β no threshold, no verdict. | |
| print() | |
| print(render_comparison(result["bundle"])) | |
| return 0 | |
| # ββ Guided setup (`init`) β interactive by default, `--yes` for scripts/CI βββ | |
| def _ask(prompt: str) -> str: | |
| try: | |
| return input(f"{prompt}: ").strip() | |
| except EOFError: | |
| raise RuntimeError("`uofa interrogate init` is interactive β run it in a terminal.") | |
| def _ask_float(prompt: str) -> float: | |
| while True: | |
| raw = _ask(prompt) | |
| try: | |
| return float(raw) | |
| except ValueError: | |
| error("enter a number.") | |
| def _ask_list(prompt: str) -> list[str]: | |
| return [tok.strip() for tok in _ask(prompt).split(",") if tok.strip()] | |
| def _ask_yes_no(prompt: str) -> bool: | |
| return _ask(f"{prompt} [y/N]").lower().startswith("y") | |
| def _ask_provenance(docs: Path | None, field: str) -> str: | |
| if docs and _ask_yes_no(f" did '{field}' come from {docs}?"): | |
| return f"extracted-from:{docs};confirmed-by-engineer" | |
| return "entered-by-engineer" | |
| def _smoke(adapter_path: Path, benchmark_path: Path, output_names: list[str]) -> tuple[bool, str]: | |
| from uofa_cli.interrogate.adapter import load_adapter | |
| from uofa_cli.interrogate import loader, init_wizard | |
| try: | |
| import numpy as np | |
| adapter = load_adapter(f"{adapter_path}:GeneratedAdapter") | |
| bench = loader.load_benchmark(benchmark_path) | |
| row = np.asarray(bench.inputs)[:1] | |
| return init_wizard.smoke_test_adapter(adapter, row, output_names) | |
| except Exception as exc: # incomplete template / framework not importable | |
| return False, str(exc) | |
| def _collect_init_interactive(args, wiz): | |
| """Prompt the engineer for inputs, envelope, evaluation point, constraints, subject. | |
| Returns ``(input_names, output_names, scope)``. build_scope tags every field | |
| with provenance, so the shared invariant check downstream is belt-and-suspenders. | |
| """ | |
| input_names = _ask_list("Name the model INPUT dimensions in physical terms (comma-separated, e.g. reynolds,aoa)") | |
| output_names = _ask_list("Name the model OUTPUT quantities of interest (comma-separated, e.g. lift_coefficient)") | |
| provenance: dict[str, str] = {} | |
| envelope_dims = [] | |
| info("Declare the training envelope (the surrogate's valid input domain):") | |
| for name in input_names: | |
| envelope_dims.append({"name": name, | |
| "min": _ask_float(f" {name}: minimum bound"), | |
| "max": _ask_float(f" {name}: maximum bound")}) | |
| provenance[f"trainingEnvelope.{name}"] = _ask_provenance(args.docs, name) | |
| eval_point = [] | |
| info("Declare the evaluation point (where this COU exercises the surrogate):") | |
| for name in input_names: | |
| eval_point.append({"name": name, "value": _ask_float(f" {name}: evaluation value")}) | |
| provenance[f"evaluationPoint.{name}"] = _ask_provenance(args.docs, name) | |
| constraints = [] | |
| while _ask_yes_no("Add a declared physics constraint?"): | |
| cid = _ask(" constraint id (e.g. mass-conservation)") | |
| constraints.append({"constraintId": cid, | |
| "description": _ask(" description"), | |
| "kind": _ask(" kind (conservation/boundary-condition/invariant/...)")}) | |
| provenance[f"constraint.{cid}"] = _ask_provenance(args.docs, cid) | |
| subject = { | |
| "surrogateId": _ask("Surrogate id"), | |
| "modelVersion": _ask("Model version"), | |
| "surrogateType": _ask(f"Surrogate type {SURROGATE_TYPES}"), | |
| "modelFingerprint": "unspecified", | |
| } | |
| scope = wiz.build_scope(subject=subject, envelope_dimensions=envelope_dims, | |
| physics_constraints=constraints, provenance=provenance, | |
| evaluation_point=eval_point) | |
| return input_names, output_names, scope | |
| def _split_csv(raw: str | None) -> list[str]: | |
| return [tok.strip() for tok in (raw or "").split(",") if tok.strip()] | |
| def _collect_init_noninteractive(args, wiz): | |
| """Non-interactive (`--yes`): adopt a pre-written ``--scope`` file verbatim. | |
| Removes the interactivity, not the A14 rigor β the scope must already carry | |
| per-field provenance (checked downstream by ``unprovenanced_scope_fields``), | |
| and reference stays a supplied input, never fabricated. Returns | |
| ``(input_names, output_names, scope)`` or ``None`` on a usage error. | |
| """ | |
| if not getattr(args, "scope", None): | |
| error("non-interactive `init --yes` needs --scope FILE β a pre-written scope carrying " | |
| "the training envelope, evaluation point, and per-field provenance (the same shape " | |
| "interactive `init` writes). Scope is never silently defaulted.") | |
| return None | |
| scope_path = Path(args.scope) | |
| if not scope_path.is_file(): | |
| error(f"--scope file not found: {scope_path}") | |
| return None | |
| scope = _load_scope(scope_path) | |
| output_names = _split_csv(getattr(args, "output_names", None)) | |
| if not output_names: | |
| error("non-interactive `init --yes` needs --output-names q1,q2,... β the QoIs the adapter " | |
| "returns (the scope does not carry them).") | |
| return None | |
| input_names = _split_csv(getattr(args, "input_names", None)) | |
| if not input_names: | |
| input_names = [d.get("name") for d in scope.get("trainingEnvelope", {}).get("dimensions", []) | |
| if d.get("name")] | |
| if not input_names: | |
| error("cannot determine model inputs: pass --input-names, or give a --scope file whose " | |
| "trainingEnvelope.dimensions each declare a name.") | |
| return None | |
| return input_names, output_names, scope | |
| def _run_init(args) -> int: | |
| from uofa_cli.interrogate import init_wizard as wiz | |
| out_dir = Path(args.out_dir) | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| fmt = wiz.detect_model_format(args.model) if args.model else "unknown" | |
| non_interactive = bool(getattr(args, "yes", False)) | |
| step_header("uofa interrogate init β " + ("non-interactive setup" if non_interactive else "guided setup")) | |
| info(f"model: {args.model or '(not provided)'} detected format: {fmt}") | |
| collected = (_collect_init_noninteractive(args, wiz) if non_interactive | |
| else _collect_init_interactive(args, wiz)) | |
| if collected is None: | |
| return 2 # usage error (non-interactive inputs incomplete) | |
| input_names, output_names, scope = collected | |
| # Invariant (both paths): never write a scope field without provenance. | |
| unprov = wiz.unprovenanced_scope_fields(scope) | |
| if unprov: | |
| hint = " (add provenance for these in the --scope file)" if non_interactive else " (internal)" | |
| error(f"scope fields without provenance: {unprov}{hint}") | |
| return 1 | |
| adapter_path = out_dir / "sip_adapter.py" | |
| adapter_path.write_text( | |
| wiz.generate_adapter_source( | |
| class_name="GeneratedAdapter", model_format=fmt, | |
| model_path=str(args.model or "PATH_TO_MODEL"), | |
| input_names=input_names, output_names=output_names, | |
| ), | |
| encoding="utf-8", | |
| ) | |
| result_line("Wrote adapter", True, str(adapter_path)) | |
| scope_path = out_dir / "sip_scope.json" | |
| scope_path.write_text(json.dumps(scope, indent=2, ensure_ascii=False), encoding="utf-8") | |
| result_line("Wrote scope", True, str(scope_path)) | |
| if args.reference: | |
| info("reference is a SUPPLIED input β SIP never generates reference values.") | |
| if args.benchmark: | |
| ok, msg = _smoke(adapter_path, args.benchmark, output_names) | |
| if not ok: | |
| error(f"adapter smoke test failed at setup: {msg}") | |
| info("Complete the generated adapter (model load / output mapping), then re-run init.") | |
| return 1 | |
| result_line("Adapter smoke test", True, "predict returned the declared QoIs") | |
| info(f"Setup complete. Next: uofa interrogate --adapter {adapter_path}:GeneratedAdapter " | |
| f"--benchmark <b> --reference <r> --scope {scope_path} -o pkg.json") | |
| return 0 | |