TileOPs-Metax/scripts/validate_manifest.py

3772 lines
145 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Validate the ops manifest.
Checks:
schema — YAML structure: required fields, types, nesting
signature — Op.forward() params match manifest inputs+params
shape — shape_rules are parseable Python expressions
dtype — dtype strings are valid torch dtype names or references
bench — benchmark file uses load_workloads and op-local eval_roofline()
Spec-only ops get schema only. Implemented ops get all checks.
Usage:
python scripts/validate_manifest.py [--verbose] [--levels schema,shape,dtype,bench] [--check-op NAME]
Exit code 0 = all checks pass; 1 = failures found.
The --levels flag selects which checks to run. When omitted, all are enabled.
"""
from __future__ import annotations
import ast
import contextlib
import importlib
import inspect
import itertools
import re
import sys
import textwrap
import types
import warnings as _warnings
from collections.abc import Collection
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
import tileops.manifest as manifest_pkg # noqa: E402
from tileops.manifest.shape_rules import ( # noqa: E402
dim_range_validity,
dim_uniqueness,
reduced_axes,
)
MANIFEST_DIR = REPO_ROOT / "tileops" / "manifest"
# Valid torch dtype base names (without same_as references)
_TORCH_DTYPES = {
"float16", "float32", "float64", "bfloat16",
"int8", "int16", "int32", "int64",
"uint8", "bool",
"complex64", "complex128",
"float8_e4m3fn", "float8_e5m2",
"float8_e4m3", "float8_e5m2fnuz", "float8_e4m3fnuz",
}
_SAME_AS_RE = re.compile(r"^same_as\(\s*(\w+)\s*\)$")
# ``promote_int_to_float(ref)``: output dtype is ``float32`` when ``ref``'s
# dtype is integral (uint8 / int8 / int16 / int32 / int64), else
# ``same_as(ref)``. Models PyTorch-style int-input promotion for ops like
# ``torch.reciprocal`` whose float32 result cannot be expressed by
# ``same_as(input)`` alone.
_PROMOTE_INT_TO_FLOAT_RE = re.compile(
r"^promote_int_to_float\(\s*(\w+)\s*\)$"
)
# Integral torch dtypes that ``promote_int_to_float`` rewrites to ``float32``.
# Restricted to the dtypes PyTorch's int-input promotion treats as integral
# (bool is excluded — it is not part of the integral promotion contract).
_PROMOTE_INT_DTYPES: frozenset[str] = frozenset({
"uint8", "int8", "int16", "int32", "int64",
})
# Target dtype for integral inputs under ``promote_int_to_float``. Matches
# PyTorch's default scalar type.
_PROMOTE_TARGET_DTYPE: str = "float32"
# Required top-level fields per op entry
_REQUIRED_TOP = {"family", "status", "signature", "workloads", "roofline", "source"}
_VALID_TOP_KEYS = _REQUIRED_TOP | {"ref_api", "variant_of", "torch_compile_fullgraph"}
_REQUIRED_SIGNATURE = {"inputs", "outputs"}
_VALID_SIGNATURE_KEYS = {
"inputs", "outputs", "params", "shape_rules", "dtype_combos",
"static_dims",
}
_REQUIRED_SOURCE = {"kernel", "op", "test", "bench"}
# Valid tensor layout values (R19)
_VALID_LAYOUTS = {"channels_last"}
# Single-axis reference: `<tensor>.shape[<int_literal_or_identifier>]` (R20)
_STATIC_DIM_EXPR_RE = re.compile(
r"^([A-Za-z_][A-Za-z0-9_]*)\.shape\[(-?\d+|[A-Za-z_][A-Za-z0-9_]*)\]$"
)
def _check_static_dims(op_name: str, sdims: object, sig: dict) -> list[str]:
"""Validate `signature.static_dims` per R20.
- Must be a mapping of str → str.
- Each value must be a single-axis reference: `<tensor>.shape[<axis>]`
where `<tensor>` is a name in `signature.inputs` and `<axis>` is either
an integer literal or a param name declared in `signature.params`.
"""
errors: list[str] = []
if not isinstance(sdims, dict):
errors.append(
f"[schema] {op_name}: signature.static_dims must be a mapping"
)
return errors
# Tolerate malformed inputs/params (reported as schema errors elsewhere):
# treat non-dicts as empty so static_dims checks don't crash the validator.
inputs = sig.get("inputs")
params = sig.get("params")
input_names = set(inputs.keys()) if isinstance(inputs, dict) else set()
param_names = set(params.keys()) if isinstance(params, dict) else set()
for dname, expr in sdims.items():
if not isinstance(expr, str):
errors.append(
f"[schema] {op_name}: static_dims.{dname} must be a "
f"string expression (got {type(expr).__name__})"
)
continue
match = _STATIC_DIM_EXPR_RE.match(expr)
if match is None:
errors.append(
f"[schema] {op_name}: static_dims.{dname} expression "
f"{expr!r} is not a single-axis reference of the form "
f"`<tensor>.shape[<const_or_param>]` (R20)"
)
continue
tensor_name, axis_ref = match.groups()
if tensor_name not in input_names:
errors.append(
f"[schema] {op_name}: static_dims.{dname} references tensor "
f"{tensor_name!r}, which is not in signature.inputs "
f"(known: {sorted(input_names) or 'none'})"
)
# axis_ref is an int literal (possibly negative) or an identifier
if not (axis_ref.lstrip("-").isdigit() or axis_ref in param_names):
errors.append(
f"[schema] {op_name}: static_dims.{dname} axis reference "
f"{axis_ref!r} is neither an integer literal nor a declared "
f"param (known params: {sorted(param_names) or 'none'})"
)
return errors
# ---------------------------------------------------------------------------
# schema: YAML structure validation
# ---------------------------------------------------------------------------
def _check_shape_rule_callables(
op_name: str, index: int, rule_str: str,
) -> list[str]:
"""Validate that bare-name calls in a shape_rule reference known helpers.
Walks the parsed rule AST; every ``ast.Call`` whose ``func`` is a
bare ``ast.Name`` must be registered in ``_SHAPE_RULE_BUILTINS``.
Method / subscript calls are skipped — only direct name lookups are
validated. A ``SyntaxError`` surfaces as a single ``[schema]`` error
so malformed rules are rejected at L0 without the L2 eval context.
Returns ``[schema]``-prefixed error strings (empty when clean).
"""
errors: list[str] = []
try:
tree = ast.parse(rule_str, mode="eval")
except SyntaxError as exc:
errors.append(
f"[schema] {op_name}: shape_rules[{index}] invalid syntax: "
f"{rule_str!r} ({exc})"
)
return errors
# _SHAPE_RULE_BUILTINS is defined later in the module; the forward
# reference is intentional. The dict resolves at call time (validation
# runs after import), and keeping its single source of truth alongside
# the helper callables it maps to avoids splitting the registry.
seen_unknown: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if not isinstance(func, ast.Name):
continue
if func.id in _SHAPE_RULE_BUILTINS or func.id in seen_unknown:
continue
seen_unknown.add(func.id)
errors.append(
f"[schema] {op_name}: shape_rules[{index}] calls unknown "
f"helper {func.id!r}; allowed callables are "
f"{', '.join(sorted(_SHAPE_RULE_BUILTINS))}"
)
return errors
def _check_single_input_workload_keys(
op_name: str, sig: dict, workloads: list,
) -> list[str]:
"""Check R21: workload keys must derive from the signature.
Out of scope: multi-input signatures and workloads with no ``*_shape``
key.
"""
contract = manifest_pkg.single_input_workload_contract(sig)
if contract is None:
return []
if not any(
isinstance(w, dict)
and any(isinstance(k, str) and k.endswith("_shape") for k in w)
for w in workloads
):
return []
shape_key, allowed = contract
params = sig.get("params")
param_names = set(params) if isinstance(params, dict) else set()
reserved = manifest_pkg.WORKLOAD_RESERVED_KEYS | {shape_key}
errors: list[str] = []
collisions = sorted(param_names & reserved)
if collisions:
errors.append(
f"[schema] {op_name}: signature params {collisions} collide "
"with reserved workload keys"
)
for i, w in enumerate(workloads):
if not isinstance(w, dict):
continue
if shape_key not in w:
errors.append(
f"[schema] {op_name}: workloads[{i}] missing {shape_key!r} "
"(shape key is derived from the signature's tensor input "
"name)"
)
unknown = sorted(
k for k in w
if isinstance(k, str) and k not in allowed and not k.startswith("__")
)
if unknown:
errors.append(
f"[schema] {op_name}: workloads[{i}] has unknown keys "
f"{unknown}; allowed are {shape_key!r}, 'dtypes', 'label', "
"and declared signature params"
)
return errors
def _l0_key_format(
op_name: str, all_op_names: Collection[str],
) -> list[str]:
"""Key format: variant words precede the direction suffix.
The direction suffix itself is required only when the manifest
carries a direction sibling of the same op.
"""
errors: list[str] = []
err = _emit_to(errors, "schema", op_name)
key_match = re.match(r"^(.*)(Fwd|Bwd)Op(.+)$", op_name)
if key_match:
stem, direction, trailing = key_match.groups()
err(
f"variant word '{trailing}' follows '{direction}Op'; variant "
f"words must precede the direction suffix "
f"(expected '{stem}{trailing}{direction}Op')"
)
elif op_name.endswith("Op") and not op_name.endswith(("FwdOp", "BwdOp")):
stem = op_name[:-2]
siblings = [
s for s in (f"{stem}FwdOp", f"{stem}BwdOp") if s in all_op_names
]
if siblings:
err(
f"missing direction suffix; direction sibling "
f"'{siblings[0]}' exists in the manifest"
)
return errors
def _l0_signature(op_name: str, entry: dict, sig: dict) -> list[str]:
"""Signature sub-schema: tensors, params, dtype_combos, shape_rules."""
errors: list[str] = []
err = _emit_to(errors, "schema", op_name)
missing_sig = _REQUIRED_SIGNATURE - set(sig.keys())
if missing_sig:
err(f"signature missing: {missing_sig}")
# inputs/outputs/params names must be strings.
for field in ("inputs", "outputs", "params"):
names = sig.get(field)
if not isinstance(names, dict):
continue
non_str = sorted(repr(k) for k in names if not isinstance(k, str))
if non_str:
err(
f"signature.{field} has non-string names "
f"[{', '.join(non_str)}]"
)
# inputs/outputs are dicts of tensor-attr dicts carrying a dtype.
for direction in ("inputs", "outputs"):
tensors = sig.get(direction)
if not isinstance(tensors, dict):
if direction in sig:
err(f"signature.{direction} must be a dict")
continue
for tname, attrs in tensors.items():
if not isinstance(attrs, dict):
err(f"{direction}.{tname} must be a dict")
continue
if "dtype" not in attrs:
err(f"{direction}.{tname} missing 'dtype'")
# constraints keys must name dims of the declared shape
if "constraints" in attrs:
constraints = attrs["constraints"]
if not isinstance(constraints, dict):
err(f"{direction}.{tname}.constraints must be a mapping")
elif not isinstance(attrs.get("shape"), str):
err(f"{direction}.{tname} has constraints but no shape")
else:
dims = {
d.strip()
for d in attrs["shape"].strip("[]").split(",")
if d.strip()
}
for ckey in constraints:
if ckey not in dims:
err(
f"{direction}.{tname} constraints key "
f"'{ckey}' is not in shape dims "
f"{sorted(dims)}"
)
# layout validation (R19)
if "layout" in attrs:
layout = attrs["layout"]
if not isinstance(layout, str):
err(f"{direction}.{tname}.layout must be a string")
elif layout not in _VALID_LAYOUTS:
err(
f"{direction}.{tname}.layout '{layout}' is not "
f"recognized "
f"(valid: {', '.join(sorted(_VALID_LAYOUTS))})"
)
# Params must be a mapping if present; each entry needs 'type' (R1).
if "params" in sig:
params = sig["params"]
if not isinstance(params, dict):
err("signature.params must be a mapping")
else:
for pname, pattrs in params.items():
if not isinstance(pattrs, dict):
err(f"params.{pname} must be a dict")
continue
if "type" not in pattrs:
err(f"params.{pname} missing 'type'")
# Surface invariant: every op produces at least one output, and has
# at least one construction handle — a tensor input or a declared
# param. ``inputs: {}`` is permitted (generative ops synthesize the
# output from params alone).
raw_inputs = sig.get("inputs")
raw_outputs = sig.get("outputs")
raw_params = sig.get("params")
inputs_count = len(raw_inputs) if isinstance(raw_inputs, dict) else 0
outputs_count = len(raw_outputs) if isinstance(raw_outputs, dict) else 0
params_count = len(raw_params) if isinstance(raw_params, dict) else 0
if outputs_count < 1:
err("signature.outputs must declare at least one tensor")
if inputs_count < 1 and params_count < 1:
err(
"signature must declare at least one input tensor or one "
"param (both are empty)"
)
# Every output declares a shape, or shape_rules pin the output shapes.
raw_rules = sig.get("shape_rules")
has_shape_rules = isinstance(raw_rules, list) and len(raw_rules) > 0
if isinstance(raw_outputs, dict) and not has_shape_rules:
for tname, attrs in raw_outputs.items():
if isinstance(attrs, dict) and "shape" not in attrs:
err(
f"output '{tname}' must declare 'shape' or the "
f"signature must have shape_rules"
)
# dtype_combos must be a list of dicts if present (R4).
if "dtype_combos" in sig:
combos = sig["dtype_combos"]
if not isinstance(combos, list):
err("signature.dtype_combos must be a list")
else:
tensor_names = set()
for d in ("inputs", "outputs"):
t = sig.get(d)
if isinstance(t, dict):
tensor_names.update(t.keys())
for i, combo in enumerate(combos):
if not isinstance(combo, dict):
err(f"dtype_combos[{i}] must be a dict")
continue
for key in combo:
if key not in tensor_names:
err(
f"dtype_combos[{i}] key '{key}' is not a "
f"declared tensor name"
)
# shape_rules must be a list of strings if present.
if "shape_rules" in sig:
rules = sig["shape_rules"]
if not isinstance(rules, list):
err("shape_rules must be a list")
else:
for i, rule in enumerate(rules):
if not isinstance(rule, str):
err(f"shape_rules[{i}] must be a string")
continue
errors.extend(_check_shape_rule_callables(op_name, i, rule))
# Unknown signature keys are silently ignored by L1+; reject here.
unknown_sig = sorted(repr(k) for k in set(sig) - _VALID_SIGNATURE_KEYS)
if unknown_sig:
err(
f"unknown signature keys [{', '.join(unknown_sig)}]; valid "
f"keys are {sorted(_VALID_SIGNATURE_KEYS)}"
)
# static_dims must be a mapping of str -> str expression (R20).
if "static_dims" in sig:
errors.extend(_check_static_dims(op_name, sig["static_dims"], sig))
return errors
def _l0_workloads(op_name: str, entry: dict, workloads: list) -> list[str]:
"""Workload policy: count, dtypes, required-param pinning, R21 keys."""
errors: list[str] = []
err = _emit_to(errors, "schema", op_name)
# Implemented ops need benchmarkable coverage: at least 2 workloads.
if entry.get("status") == "implemented" and len(workloads) < 2:
err(
f"implemented op must have at least 2 workloads, "
f"got {len(workloads)}"
)
# Params without a default must be pinned by every workload.
sig_params = entry.get("signature", {})
sig_params = sig_params.get("params") if isinstance(sig_params, dict) else None
required_params = {
pname
for pname, pattrs in (sig_params or {}).items()
if isinstance(pname, str)
and isinstance(pattrs, dict)
and "default" not in pattrs
} if isinstance(sig_params, dict) else set()
for i, w in enumerate(workloads):
if not isinstance(w, dict):
err(f"workloads[{i}] must be a dict")
continue
if "dtypes" not in w:
err(f"workloads[{i}] missing 'dtypes'")
non_str = [k for k in w if not isinstance(k, str)]
if non_str:
err(f"workloads[{i}] has non-string keys {non_str}")
missing_params = required_params - set(w.keys())
if missing_params:
err(
f"workloads[{i}] missing required param(s): "
f"{sorted(missing_params)}"
)
if isinstance(entry.get("signature"), dict):
errors.extend(
_check_single_input_workload_keys(
op_name, entry["signature"], workloads
)
)
return errors
def _l0_roofline(op_name: str, entry: dict, roofline: dict) -> list[str]:
"""Roofline structural rules per docs/design/roofline.md §4.1."""
errors: list[str] = []
err = _emit_to(errors, "schema", op_name)
has_inline = "flops" in roofline and "bytes" in roofline
has_func = "func" in roofline
if not has_inline and not has_func:
err("roofline must have (flops + bytes) or func")
if has_func and ({"flops", "bytes", "vars"} & set(roofline)):
err(
"roofline modes are exclusive — func must not coexist with "
"flops/bytes/vars"
)
for field in ("flops", "bytes", "func"):
if field in roofline and not (
isinstance(roofline[field], str) and roofline[field].strip()
):
err(f"roofline.{field} must be a non-empty string")
rl_vars = roofline.get("vars")
if rl_vars is not None:
if not isinstance(rl_vars, dict):
err("roofline.vars must be a mapping")
else:
for k, v in rl_vars.items():
if not isinstance(k, str):
err(f"roofline.vars key {k!r} must be a string")
if not (isinstance(v, str) and v.strip()):
err(f"roofline.vars[{k!r}] must be a non-empty string")
if has_func and isinstance(roofline.get("func"), str):
mod, _, attr = roofline["func"].rpartition(".")
try:
target = importlib.import_module(mod) if mod else None
except ImportError:
target = None
if target is None or not callable(getattr(target, attr, None)):
err(
f"roofline.func {roofline['func']!r} does not resolve "
f"to a callable"
)
return errors
def _l0_source(op_name: str, entry: dict, source: dict) -> list[str]:
"""Source block: required path fields; kernel string-or-list."""
errors: list[str] = []
err = _emit_to(errors, "schema", op_name)
missing_src = _REQUIRED_SOURCE - set(source.keys())
if missing_src:
err(f"source missing fields: {missing_src}")
# source.kernel: string or list of strings
kernel = source.get("kernel")
if kernel is not None:
if isinstance(kernel, list):
for i, k in enumerate(kernel):
if not isinstance(k, str):
err(f"source.kernel[{i}] must be a string")
elif not isinstance(kernel, str):
err("source.kernel must be a string or list")
if "bench_manifest_driven" in source and not isinstance(
source["bench_manifest_driven"], bool,
):
err("source.bench_manifest_driven must be a bool")
return errors
def _l0_kernel_map(
op_name: str, entry: dict, warnings: list[str] | None,
) -> list[str]:
"""kernel_map (under source): mapping of str -> str.
Missing kernel_map on an implemented op is advisory (warning), not
an error.
"""
errors: list[str] = []
err = _emit_to(errors, "schema", op_name)
source = entry.get("source", {})
kernel_map = source.get("kernel_map") if isinstance(source, dict) else None
if kernel_map is not None:
if not isinstance(kernel_map, dict):
err(
f"kernel_map must be a mapping, "
f"got {type(kernel_map).__name__}"
)
else:
for k, v in kernel_map.items():
if not isinstance(k, str) or not isinstance(v, str):
err(
f"kernel_map entries must be str -> str, "
f"got {k!r}: {v!r}"
)
elif entry.get("status") == "implemented" and warnings is not None:
warnings.append(
f"[schema] {op_name}: status is 'implemented' but "
f"kernel_map is missing (should be a mapping of str -> str)"
)
return errors
# Table-driven L0 sections, in emission order. Each row: (field, expected
# container type, type-error phrase, section validator run on type match).
# Genuinely custom rules (key format, scalar fields, kernel_map) stay as
# dedicated small validators around the table loop in ``check_l0``.
_L0_SECTIONS = (
("signature", dict, "a mapping", _l0_signature),
("workloads", list, "a list", _l0_workloads),
("roofline", dict, "a mapping", _l0_roofline),
("source", dict, "a mapping", _l0_source),
)
def check_l0(
op_name: str, entry: dict, *, warnings: list[str] | None = None,
all_op_names: Collection[str] = (),
) -> list[str]:
"""Validate structural schema of a manifest entry. Returns error strings."""
if not isinstance(entry, dict):
return [
f"[schema] {op_name}: entry must be a mapping, "
f"got {type(entry).__name__}"
]
errors: list[str] = []
err = _emit_to(errors, "schema", op_name)
errors.extend(_l0_key_format(op_name, all_op_names))
# Top-level required fields
missing_top = _REQUIRED_TOP - set(entry.keys())
if missing_top:
err(f"missing top-level fields: {missing_top}")
for field, container, desc, section in _L0_SECTIONS:
value = entry.get(field)
if isinstance(value, container):
errors.extend(section(op_name, entry, value))
elif field in entry:
err(f"{field} must be {desc}")
# Unknown top-level keys are ignored by every later level, so reject
# them here (covers removed fields like parity_opt_out).
unknown_top = sorted(repr(k) for k in set(entry) - _VALID_TOP_KEYS)
if unknown_top:
err(
f"unknown entry keys [{', '.join(unknown_top)}]; "
f"valid keys are {sorted(_VALID_TOP_KEYS)}"
)
# variant_of: must be a string if present (R16); cross-entry checks
# in check_variant_of_consistency().
if "variant_of" in entry and not isinstance(entry["variant_of"], str):
err("variant_of must be a string")
# ref_api: required string — fully qualified PyTorch API equivalent
# or "none".
if "ref_api" not in entry:
err("missing required field 'ref_api'")
elif not isinstance(entry["ref_api"], str):
err("ref_api must be a string")
# status: must be "implemented" or "spec-only"
# (missing status is already caught by the required-fields check).
status = entry.get("status")
if "status" in entry and not isinstance(status, str):
err(f"status must be a string, got {type(status).__name__}")
elif isinstance(status, str) and status not in ("implemented", "spec-only"):
err(
f"status must be 'implemented' or 'spec-only', "
f"got '{status}'"
)
# torch_compile_fullgraph: optional capability flag declaring that
# torch.compile(op, fullgraph=True) succeeds cold-call. Only literal
# `true` is accepted; absence is the only spelling of "no promise".
# Invalid on `status: spec-only` entries — a spec without an
# implementation cannot promise graph capture.
if "torch_compile_fullgraph" in entry:
tcf = entry["torch_compile_fullgraph"]
if tcf is not True:
err(
f"torch_compile_fullgraph must be literal true when "
f"present (omit the field to make no promise), got {tcf!r}"
)
elif status == "spec-only":
err(
"torch_compile_fullgraph is invalid on 'status: "
"spec-only' entries — the promise requires an "
"implementation"
)
errors.extend(_l0_kernel_map(op_name, entry, warnings))
return errors
def check_source_paths(op_name: str, entry: dict, repo_root: Path) -> list[str]:
"""Check that string ``source`` values of non-spec-only ops are real files.
Spec-only entries are skipped — their source paths are placeholders
until implementation. Non-string values (e.g. ``kernel_map`` mappings,
``source.kernel`` lists, nulls) are out of scope here; their structure
is validated by :func:`check_l0`.
"""
if not isinstance(entry, dict) or _is_spec_only(entry):
return []
source = entry.get("source")
if not isinstance(source, dict):
return []
errors: list[str] = []
for key, rel_path in source.items():
if not isinstance(rel_path, str):
continue
if not (repo_root / rel_path).is_file():
errors.append(
f"[schema] {op_name}: source.{key} is not a file: {rel_path}"
)
return errors
# ---------------------------------------------------------------------------
# variant_of: cross-entry consistency (R16)
# ---------------------------------------------------------------------------
def check_variant_of_consistency(
ops: dict, *, scope: set[str] | None = None
) -> list[str]:
"""Validate variant_of references across all entries.
Per R16: variant_of must reference an existing op; the primary must
not itself be a variant (no chaining); variant and primary must
share source.kernel and source.op. When *scope* is given, only ops
named in *scope* are checked; lookups still use the full dict so
reference resolution works.
"""
errors: list[str] = []
for op_name, entry in ops.items():
if scope is not None and op_name not in scope:
continue
if not isinstance(entry, dict):
continue # malformed entry — check_l0 will report it
primary_name = entry.get("variant_of")
if primary_name is None:
continue
# Target must exist
if primary_name not in ops:
errors.append(
f"[schema] {op_name}: variant_of '{primary_name}' "
f"does not exist in the manifest"
)
continue
primary = ops[primary_name]
if not isinstance(primary, dict):
continue # malformed primary — check_l0 will report it
# Single-level: primary must not be a variant itself
if "variant_of" in primary:
errors.append(
f"[schema] {op_name}: variant_of '{primary_name}' is itself "
f"a variant (chaining not allowed per R16)"
)
# Shared source.kernel and source.op
src = entry.get("source", {})
pri_src = primary.get("source", {})
if src.get("kernel") != pri_src.get("kernel"):
errors.append(
f"[schema] {op_name}: source.kernel differs from primary "
f"'{primary_name}' (must match per R16)"
)
if src.get("op") != pri_src.get("op"):
errors.append(
f"[schema] {op_name}: source.op differs from primary "
f"'{primary_name}' (must match per R16)"
)
return errors
# ---------------------------------------------------------------------------
# signature: Op.forward() vs manifest consistency
# ---------------------------------------------------------------------------
def check_l1_signature(
op_name: str,
manifest_inputs: dict,
manifest_params: dict,
forward_params: list[str],
*,
init_params: list[str] | None = None,
manifest_static_dims: dict | None = None,
) -> list[str]:
"""Check that forward() params match manifest inputs + params.
The strict rule: every manifest-declared param must appear in the union
of ``__init__()`` and ``forward()`` parameter names. Manifest inputs must
appear in ``forward()`` in declaration order. Every ``static_dims`` key
must appear as an ``__init__()`` parameter (per R20).
``init_params=None`` is treated as empty (only forward is checked).
"""
errors: list[str] = []
err = _emit_to(errors, "signature", op_name)
# Guard: manifest_params must be a dict (schema should catch this, but be safe)
if not isinstance(manifest_params, dict):
err(
"signature.params is not a mapping, cannot validate "
"forward() consistency"
)
return errors
if init_params is None:
init_params = []
# forward() order check: manifest inputs + forward-visible params, in
# order. Empty manifest inputs collapses to a forward-visible-params
# equality check (no inputs to align).
expected = list(manifest_inputs.keys()) + [
name for name in manifest_params.keys() if name in forward_params
]
if forward_params != expected:
err(
f"forward() params {forward_params} do not match "
f"manifest order {expected}"
)
# Strict subset check: every manifest param must exist in init OR forward
code_params = set(forward_params) | set(init_params)
for pname in manifest_params:
if pname not in code_params:
err(
f"manifest param {pname!r} not found in __init__() or "
f"forward() parameters"
)
# static_dims check (R20): every static_dims key must be an __init__ param
if manifest_static_dims:
if not isinstance(manifest_static_dims, dict):
err("signature.static_dims is not a mapping")
else:
init_param_set = set(init_params)
for dim_name in manifest_static_dims:
if dim_name not in init_param_set:
err(
f"static_dims key {dim_name!r} not found in "
f"__init__() parameters (R20: static_dims keys "
f"are required __init__ params)"
)
return errors
class _ResolveResult:
"""Result of attempting to resolve an Op class from a module path."""
__slots__ = ("cls", "import_error", "warning")
def __init__(self, cls=None, import_error: bool = False, warning: str = ""):
self.cls = cls
self.import_error = import_error
self.warning = warning
def _resolve_op_class(op_file: str, op_name: str) -> _ResolveResult:
"""Try to import the Op class from the source.op file.
Returns a _ResolveResult: ``cls`` set when found; ``import_error``
True when the module could not be imported (missing dependencies).
"""
# "tileops/ops/norm/rms_norm.py" -> "tileops.ops.norm.rms_norm"
mod_path = op_file.replace("/", ".").replace(".py", "")
try:
mod = importlib.import_module(mod_path)
except (ImportError, ModuleNotFoundError):
return _ResolveResult(import_error=True)
except Exception:
return _ResolveResult()
# Candidates: classes defined in this module with a forward() method.
seen_ids: set[int] = set()
candidates = []
for _name, obj in inspect.getmembers(mod, inspect.isclass):
if obj.__module__ != mod.__name__:
continue
if id(obj) in seen_ids:
continue
if hasattr(obj, "forward") and callable(obj.forward):
seen_ids.add(id(obj))
candidates.append(obj)
if not candidates:
return _ResolveResult()
# Require exact class-name identity: cls.__name__ == manifest key.
# No single-candidate bypass, no heuristic fallback.
direct = [c for c in candidates if c.__name__ == op_name]
if len(direct) == 1:
return _ResolveResult(cls=direct[0])
if len(direct) > 1:
match_names = [c.__name__ for c in direct]
ambiguity_msg = (
f"Ambiguous op class resolution for '{op_name}': "
f"multiple classes named '{op_name}' in '{op_file}': {match_names}. "
f"Returning unresolved (cls=None)."
)
_warnings.warn(ambiguity_msg, UserWarning, stacklevel=2)
return _ResolveResult(warning=ambiguity_msg)
# No exact match found among multiple candidates.
candidate_names = [c.__name__ for c in candidates]
ambiguity_msg = (
f"No class named '{op_name}' found in '{op_file}'. "
f"Candidates: {candidate_names}. "
f"Manifest key must exactly match cls.__name__."
)
_warnings.warn(ambiguity_msg, UserWarning, stacklevel=2)
return _ResolveResult(warning=ambiguity_msg)
_EXPLICIT_KINDS = {
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
}
def _get_forward_params(cls) -> list[str] | None:
"""Get explicit parameter names of cls.forward(), excluding 'self'.
Only returns explicitly named parameters — *args and **kwargs are
excluded because manifest params must appear as named arguments.
"""
try:
sig = inspect.signature(cls.forward)
return [
p for p, v in sig.parameters.items()
if p != "self" and v.kind in _EXPLICIT_KINDS
]
except (ValueError, TypeError):
return None
_POSITIONAL_KINDS = (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
)
def _forward_positional_params(cls) -> list[str] | None:
"""Get positional parameter names of cls.forward(), excluding 'self'.
Only POSITIONAL_ONLY / POSITIONAL_OR_KEYWORD count. KEYWORD_ONLY
params (those after ``*``) are not part of the positional tuple
that manifest ``signature.inputs`` aligns against. Shared by check_l1
and the C4 forward-signature parity check so they stay in lockstep.
"""
try:
sig = inspect.signature(cls.forward)
return [
p for p, v in sig.parameters.items()
if p != "self" and v.kind in _POSITIONAL_KINDS
]
except (ValueError, TypeError) as exc:
# Stash exception text so callers that surface diagnostics can
# report ``exc.__class__.__name__: exc`` without changing the
# ``None`` return contract for "not inspectable".
_forward_positional_params._last_error = ( # type: ignore[attr-defined]
f"{exc.__class__.__name__}: {exc}"
)
return None
def _get_init_params(cls) -> list[str]:
"""Get explicit parameter names of cls.__init__(), excluding 'self'.
Only returns explicitly named parameters — *args and **kwargs are
excluded. Handles monkey-patched ``__init__`` methods: if the live
signature has no explicit params, walk the MRO to find the first
concrete ``__init__`` with explicit parameters.
"""
def _extract(func):
try:
sig = inspect.signature(func)
params = [
p for p, v in sig.parameters.items()
if p != "self" and v.kind in _EXPLICIT_KINDS
]
if not params:
return None # no explicit params — try next in MRO
return params
except (ValueError, TypeError):
return None
# Try the live __init__ first
result = _extract(cls.__init__)
if result is not None:
return result
# Walk MRO for the first concrete __init__
for base in cls.__mro__[1:]:
if "__init__" in base.__dict__:
result = _extract(base.__dict__["__init__"])
if result is not None:
return result
return []
def check_l1(
op_name: str, entry: dict, *, warnings: list[str] | None = None,
) -> list[str]:
"""Signature check: resolve Op class and compare forward() to manifest.
Checks both ``__init__()`` and ``forward()`` parameter names against
the manifest signature. Returns error strings (empty if OK).
"""
errors: list[str] = []
sig = entry.get("signature", {})
source = entry.get("source", {})
op_file = source.get("op", "")
if not op_file:
if entry.get("status") == "spec-only":
if warnings is not None:
warnings.append(
f"[signature] {op_name}: skipped because status is spec-only "
"and source.op is null"
)
return []
return [f"[signature] {op_name}: missing source.op"]
result = _resolve_op_class(op_file, op_name)
if result.warning and warnings is not None:
warnings.append(f"[signature] {op_name}: {result.warning}")
if result.import_error:
errors.append(
f"[signature] {op_name}: could not import {op_file} "
f"(missing dependencies)"
)
return errors
if result.cls is None:
errors.append(f"[signature] {op_name}: could not resolve Op class from {op_file}")
return errors
forward_params = _get_forward_params(result.cls)
if forward_params is None:
errors.append(
f"[signature] {op_name}: could not inspect forward() on {result.cls.__name__}"
)
return errors
manifest_inputs = sig.get("inputs", {})
manifest_params = sig.get("params", {})
manifest_static_dims = sig.get("static_dims")
init_params = _get_init_params(result.cls)
return check_l1_signature(
op_name, manifest_inputs, manifest_params, forward_params,
init_params=init_params,
manifest_static_dims=manifest_static_dims,
)
# ---------------------------------------------------------------------------
# shape: shape_rules syntax validation
# ---------------------------------------------------------------------------
def check_l2(op_name: str, entry: dict) -> list[str]:
"""Validate shape_rules are parseable Python expressions."""
errors: list[str] = []
sig = entry.get("signature", {})
rules = sig.get("shape_rules", [])
for i, rule in enumerate(rules):
if not isinstance(rule, str):
continue
try:
ast.parse(rule, mode="eval")
except SyntaxError as exc:
errors.append(
f"[shape] {op_name}: shape_rules[{i}] invalid syntax: {rule!r} ({exc})"
)
return errors
# ---------------------------------------------------------------------------
# dtype: dtype string conformance
# ---------------------------------------------------------------------------
def _parse_dtype_expr(dtype_str: str) -> list[str]:
"""Parse a dtype expression into individual dtype tokens.
Handles: "float16", "float16 | bfloat16", "same_as(x)".
Returns list of raw tokens (may include same_as references).
"""
return [t.strip() for t in dtype_str.split("|")]
def _validate_dtype_token(
op_name: str,
context: str,
token: str,
tensor_names: set[str],
*,
allow_promote_int_to_float: bool = True,
input_tensor_names: set[str] | None = None,
) -> str | None:
"""Validate a single dtype token. Returns an error string or None.
``promote_int_to_float(ref)`` is an output-side-only construct per
docs/design/manifest.md R3a. Callers validating input tensors set
``allow_promote_int_to_float=False`` to reject it on the input side.
When ``allow_promote_int_to_float`` is True, ``input_tensor_names``
must be supplied: ``ref`` must name a signature input tensor — not
an output, and not the tensor itself.
"""
m = _SAME_AS_RE.match(token)
if m:
ref = m.group(1)
if ref not in tensor_names:
return (
f"[dtype] {op_name}: {context} dtype same_as({ref}) "
f"references unknown tensor"
)
return None
m = _PROMOTE_INT_TO_FLOAT_RE.match(token)
if m:
if not allow_promote_int_to_float:
return (
f"[dtype] {op_name}: {context} uses promote_int_to_float "
f"— this construct is output-side only"
)
ref = m.group(1)
if input_tensor_names is None or ref not in input_tensor_names:
return (
f"[dtype] {op_name}: {context} dtype "
f"promote_int_to_float({ref}) must reference a signature "
f"input tensor"
)
return None
if token not in _TORCH_DTYPES:
return f"[dtype] {op_name}: {context} has unrecognized dtype '{token}'"
return None
def _build_same_as_map(all_tensors: dict) -> dict[str, str]:
"""Map tensor name → same_as reference target for pure same_as dtypes.
Mixed expressions (``float16 | same_as(x)``) are not tracked.
"""
same_as_map: dict[str, str] = {}
for tname, attrs in all_tensors.items():
tokens = _parse_dtype_expr(attrs.get("dtype", ""))
if len(tokens) == 1:
m = _SAME_AS_RE.match(tokens[0])
if m:
same_as_map[tname] = m.group(1)
return same_as_map
def _check_dtype_combos_same_as_identity(
op_name: str, dtype_combos: list, same_as_map: dict[str, str],
) -> list[str]:
"""Enforce same_as identity in dtype_combos entries (R3).
Every tensor bound by same_as(ref) must have the exact same dtype as
its reference tensor in every combo row.
"""
errors: list[str] = []
err = _emit_to(errors, "dtype", op_name)
for i, combo in enumerate(dtype_combos):
if not isinstance(combo, dict):
continue
for tensor, ref in same_as_map.items():
t_in = tensor in combo
r_in = ref in combo
if t_in and r_in and combo[tensor] != combo[ref]:
err(
f"dtype_combos[{i}] violates same_as identity "
f"constraint — {tensor} ({combo[tensor]}) must match "
f"{ref} ({combo[ref]}) per R3"
)
elif t_in and not r_in:
err(
f"dtype_combos[{i}] has same_as-bound tensor "
f"'{tensor}' without its reference '{ref}' — cannot "
f"verify identity"
)
return errors
def check_l3(op_name: str, entry: dict) -> list[str]:
"""Validate dtype strings are recognized torch types or same_as references.
Checks both signature tensor dtypes and workload dtype entries.
Also enforces same_as identity constraint in dtype_combos (R3).
"""
errors: list[str] = []
err = _emit_to(errors, "dtype", op_name)
sig = entry.get("signature", {})
raw_inputs = sig.get("inputs")
raw_outputs = sig.get("outputs")
inputs = raw_inputs if isinstance(raw_inputs, dict) else {}
outputs = raw_outputs if isinstance(raw_outputs, dict) else {}
all_tensors = {}
all_tensors.update(inputs)
all_tensors.update(outputs)
tensor_names = set(all_tensors.keys())
input_names = set(inputs.keys())
# Signature tensor dtypes. ``promote_int_to_float`` is output-side
# only (R3a) — reject it on input tensors.
for tname, attrs in all_tensors.items():
if not isinstance(attrs, dict):
continue
for token in _parse_dtype_expr(attrs.get("dtype", "")):
token_err = _validate_dtype_token(
op_name, tname, token, tensor_names,
allow_promote_int_to_float=tname not in input_names,
input_tensor_names=input_names,
)
if token_err:
errors.append(token_err)
# same_as identity constraint in dtype_combos (R3)
dtype_combos = sig.get("dtype_combos", [])
if isinstance(dtype_combos, list) and dtype_combos:
same_as_map = _build_same_as_map(all_tensors)
errors.extend(
_check_dtype_combos_same_as_identity(op_name, dtype_combos, same_as_map)
)
# Hard data-validation for combo values, run unconditionally —
# independent of whether the op overrides ``_validate_dtypes`` —
# so an un-migrated op carrying invalid combo data still surfaces
# a hard L3 error rather than only a missing-override warning.
errors.extend(check_l3_dtype_combos_data(op_name, sig))
# Workload dtypes
workloads = entry.get("workloads", [])
if isinstance(workloads, list):
for i, w in enumerate(workloads):
if not isinstance(w, dict):
continue
dtypes = w.get("dtypes", [])
if not isinstance(dtypes, list):
continue
for j, dt in enumerate(dtypes):
if not isinstance(dt, str):
err(f"workloads[{i}].dtypes[{j}] is not a string")
continue
for token in _parse_dtype_expr(dt):
token_err = _validate_dtype_token(
op_name, f"workloads[{i}].dtypes[{j}]",
token, tensor_names,
allow_promote_int_to_float=False,
)
if token_err:
errors.append(token_err)
return errors
def _diagnose_unresolvable_signature(op_name: str, sig: dict) -> list[str]:
"""Emit hard L3 errors describing why a signature failed to resolve.
Called when ``_resolve_tensor_dtype_options(sig)`` returns None inside
combo validation. Walks the pure ``same_as`` edges to distinguish:
* dangling references (``same_as(ref)`` where ``ref`` is not a
declared tensor) — per-tensor error;
* ``same_as`` cycles (``x -> y -> ... -> x``) — one error per cycle
naming every participating tensor;
* an unknown-token / ``same_as`` in a mixed expression that resolves
to nothing — generic fallback, so callers are never left guessing.
"""
errors: list[str] = []
err = _emit_to(errors, "dtype", op_name)
inputs = sig.get("inputs") or {}
outputs = sig.get("outputs") or {}
all_tensors: dict[str, dict] = {}
if isinstance(inputs, dict):
all_tensors.update(inputs)
if isinstance(outputs, dict):
all_tensors.update(outputs)
# Pure ``same_as(ref)`` edges only — mixed expressions are not part
# of the cycle graph; a cycle in pure edges is what stalls fixpoint
# resolution.
edges: dict[str, str] = {}
for tname, attrs in all_tensors.items():
if not isinstance(attrs, dict):
continue
tokens = _parse_dtype_expr(attrs.get("dtype", ""))
if len(tokens) == 1:
m = _SAME_AS_RE.match(tokens[0])
if m:
edges[tname] = m.group(1)
# Dangling references: ``same_as(ref)`` where ``ref`` is not declared.
dangling: set[str] = set()
for tname, ref in edges.items():
if ref not in all_tensors:
err(
f"signature.inputs/outputs — tensor {tname!r} declares "
f"dtype same_as({ref}) but {ref!r} is not a declared "
f"tensor (dangling reference; combo validation cannot "
f"proceed)"
)
dangling.add(tname)
# Cycle detection via DFS over pure same_as edges. A chain ending in
# a dangling ref is not a cycle.
reported_cycles: set[frozenset[str]] = set()
visited: set[str] = set()
for start in edges:
if start in visited or start in dangling:
continue
path: list[str] = []
seen_in_path: dict[str, int] = {}
node: str | None = start
while node is not None and node not in visited:
if node in seen_in_path:
cycle_nodes = path[seen_in_path[node]:]
key = frozenset(cycle_nodes)
if key not in reported_cycles:
reported_cycles.add(key)
err(
f"same_as cycle detected among tensors "
f"{sorted(cycle_nodes)} — dtype options cannot "
f"be resolved (combo validation skipped)"
)
break
seen_in_path[node] = len(path)
path.append(node)
nxt = edges.get(node)
if nxt is None or nxt in dangling:
break
if nxt not in edges:
# Chain terminates at a concrete-dtype tensor — not a
# cycle. Stop walking.
break
node = nxt
visited.update(path)
if not errors:
# Fixpoint failed but no cycle or dangling edge was found (e.g. a
# mixed expression containing an unknown token that did not trip
# per-token validation). Emit a generic hard error so combo
# validation is never silently skipped.
err(
"could not resolve signature.inputs/outputs dtype options — "
"combo validation cannot proceed. Check "
"signature.inputs/outputs dtype declarations for unresolved "
"same_as references or malformed expressions."
)
return errors
def check_l3_dtype_combos_data(op_name: str, sig: dict) -> list[str]:
"""Validate ``dtype_combos`` entries resolve to concrete torch dtypes.
Manifest-data check, independent of any op class / ``_validate_dtypes``
implementation. Every combo value must be either a concrete dtype
name in ``_TORCH_DTYPES`` or a ``same_as(ref)`` expression whose ref
resolves transitively to concrete dtype names. Anything else is a
hard L3 error — callers must not silently proceed with invalid combo
data.
"""
errors: list[str] = []
err = _emit_to(errors, "dtype", op_name)
dtype_combos = sig.get("dtype_combos")
if not isinstance(dtype_combos, list) or not dtype_combos:
return errors
dtype_options = _resolve_tensor_dtype_options(sig)
if dtype_options is None:
# Unresolvable signature. A pure ``same_as`` cycle satisfies
# per-token validation *and* the R3 identity check, so returning
# silently here would let invalid combo data pass. Emit a hard
# L3 error with a specific diagnosis (cycle / dangling
# reference) when possible.
errors.extend(_diagnose_unresolvable_signature(op_name, sig))
return errors
inputs = sig.get("inputs") or {}
declared_input_names: list[str] = (
list(inputs.keys()) if isinstance(inputs, dict) else []
)
for i, combo in enumerate(dtype_combos):
if not isinstance(combo, dict):
continue
# Combo-row completeness: every declared signature.inputs tensor
# must be assigned a dtype in every combo row; otherwise a row
# omitting an input would pass L3 when no ``_validate_dtypes``
# override exists (``_combo_accepted`` never runs for it).
for input_name in declared_input_names:
if input_name not in combo:
err(
f"dtype_combos[{i}] is missing declared input "
f"{input_name!r} (every combo row must cover every "
f"signature.inputs tensor)"
)
for key, val in combo.items():
if not isinstance(val, str):
err(f"dtype_combos[{i}].{key} = {val!r} is not a string")
continue
# Per manifest.md R4, each combo value pins a single concrete
# dtype token (or a ``same_as(ref)`` naming a sibling in the
# same row). A union would let an implementation silently
# widen the accepted-dtype set beyond what was authored.
if "|" in val:
err(
f"dtype_combos[{i}].{key} = {val!r} — combo values "
f"must be a single concrete dtype, not a union"
)
continue
# promote_int_to_float(ref) may expand to multiple concrete
# dtypes, so it cannot pin a combo row either; authors expand
# the rows manually or use same_as(ref).
if _PROMOTE_INT_TO_FLOAT_RE.match(val):
err(
f"dtype_combos[{i}].{key} = {val!r} — combo values "
f"must be a single concrete dtype; "
f"promote_int_to_float(...) is allowed only on "
f"signature.outputs"
)
continue
opts = _dtype_options_for_tensor(key, val, dtype_options)
if opts is None:
err(
f"dtype_combos[{i}].{key} = {val!r} is not a valid "
f"dtype (unresolved same_as reference or not in "
f"torch dtype set)"
)
elif not all(t in _TORCH_DTYPES for t in opts):
bad = [t for t in opts if t not in _TORCH_DTYPES]
err(
f"dtype_combos[{i}].{key} = {val!r} resolves to "
f"unknown dtype(s) {bad!r}"
)
return errors
# ---------------------------------------------------------------------------
# shape parity: _infer_output_shapes vs shape_rules (L2 extension)
# ---------------------------------------------------------------------------
# Default mock size for symbolic shape dims: small for cheap evaluation,
# 4 avoids degenerate cases (e.g. shape[0]==1 matching scalar broadcasts).
# Distinct symbolic dims get ``_MOCK_DIM_SIZE + counter`` so cross-tensor
# equality checks remain meaningful (see ``_mock_input_shapes``).
_MOCK_DIM_SIZE = 4
# Safety bound for Cartesian-product enumeration in L3 dtype parity: an op
# with many inputs × wide dtype unions could blow CI budgets. Over-bound
# ops are skipped deterministically with a warning (no sampling), so
# validation output stays reproducible.
_MAX_DTYPE_COMBOS = 4096
# Sentinel pool used only by the same_as-identity negative probe, where
# the goal is a dtype *different from the ref's baseline*. The
# out-of-union probes derive their candidate pool from
# ``sorted(_TORCH_DTYPES - declared)`` instead, guaranteeing a non-empty
# probe whenever declared does not cover the entire torch dtype universe.
_DTYPE_SENTINELS: tuple[str, ...] = (
"float16", "bfloat16", "float32", "float64",
"int8", "int16", "int32", "int64",
)
def _out_of_union_candidates(declared: set[str]) -> list[str]:
"""Return torch dtypes outside ``declared``, sorted for reproducibility.
Bounded because ``_TORCH_DTYPES`` is a fixed small set; callers still
cap iteration via ``_MAX_DTYPE_COMBOS`` when combining with other
enumeration.
"""
return sorted(_TORCH_DTYPES - declared)
class _MockShape(tuple):
"""Tuple subclass representing a tensor shape, exposed via ``.shape``.
Used in the shape_rules evaluation context so expressions like
``x.shape == (B, S, H, D)`` or ``x.ndim`` resolve correctly without
constructing real tensors.
"""
@property
def shape(self) -> "tuple": # type: ignore[override]
return tuple(self)
@property
def ndim(self) -> int:
return len(self)
_SHAPE_EQ_RE = re.compile(
r"^\s*([A-Za-z_][A-Za-z0-9_]*)\.shape\s*==\s*\(([^)]*)\)\s*$"
)
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def _shape_eq_literals(rules: list) -> list[tuple[str, list[str]]]:
"""Extract ``<name>.shape == (<parts>...)`` literals from shape_rules.
Returns ``(tensor_name, parts)`` pairs in rule order; empty parts are
dropped. Only this simple literal form is recognized — consumers
decide whether the parts must be bare identifiers.
"""
out: list[tuple[str, list[str]]] = []
for rule in rules:
if not isinstance(rule, str):
continue
m = _SHAPE_EQ_RE.match(rule)
if m is None:
continue
parts = [p.strip() for p in m.group(2).split(",") if p.strip()]
out.append((m.group(1), parts))
return out
def _extract_shape_tuple_literals(rules: list) -> dict[str, int]:
"""Parse ``<name>.shape == (<ids>...)`` rules for input-tensor rank hints.
Only the all-bare-identifier form contributes a rank; other
shape_rules patterns are skipped.
"""
ranks: dict[str, int] = {}
for name, parts in _shape_eq_literals(rules):
if all(_IDENT_RE.fullmatch(p) for p in parts):
ranks[name] = len(parts)
return ranks
_SHAPE_DECL_RE = re.compile(r"^\s*\[([^\]]*)\]\s*$")
def _parse_shape_decl(shape_str: str) -> list[str] | None:
"""Parse a ``signature.inputs[*].shape`` declaration like ``"[N, C, L]"``.
Returns the list of dimension identifiers if the declaration is a bare
comma-separated identifier list; returns None otherwise (e.g. contains
arithmetic, literals, or other expressions that cannot be bound as
mock dim names by this tool).
"""
if not isinstance(shape_str, str):
return None
m = _SHAPE_DECL_RE.match(shape_str)
if m is None:
return None
parts = [p.strip() for p in m.group(1).split(",") if p.strip()]
if not parts:
return None
if not all(_IDENT_RE.fullmatch(p) for p in parts):
return None
return parts
def _input_bound_symbols(sig: dict) -> set[str]:
"""Return symbolic dim names bound by INPUT shapes only.
A symbol is input-bound when it appears in either a
``<input>.shape == (...)`` literal in ``signature.shape_rules`` or a
``signature.inputs[*].shape`` declaration like ``"[N, C, L]"``.
Symbols that appear only in output shape declarations (e.g. a conv
``L_out`` derived by a shape_rules formula) are **not** included:
the L2 parity check compares input-bound symbols against concrete
mock sizes, while output-only symbols carry values derived by
``_infer_output_shapes`` — comparing those against arbitrary mock
sizes would misreport a correct implementation.
"""
bound: set[str] = set()
rules = sig.get("shape_rules") or []
inputs_raw = sig.get("inputs")
inputs = inputs_raw if isinstance(inputs_raw, dict) else {}
input_names = set(inputs.keys())
for tname, parts in _shape_eq_literals(rules):
if tname not in input_names:
continue
for p in parts:
if _IDENT_RE.fullmatch(p):
bound.add(p)
# Per-tensor shape decl on inputs
for attrs in inputs.values():
if not isinstance(attrs, dict):
continue
parts = _parse_shape_decl(attrs.get("shape", ""))
if parts is not None:
bound.update(parts)
return bound
def _mock_input_shapes(
sig: dict,
) -> tuple[dict[str, _MockShape], dict[str, int]] | None:
"""Derive concrete mock input shapes for every declared input.
Uses rank hints from ``shape_rules`` (literal ``tensor.shape == (...)``
forms) and from ``signature.inputs[*].shape`` declarations, falling
back to a default 2D shape when the rank is unknown. Returns
``(shapes, dim_sizes)`` where ``dim_sizes`` maps each symbolic dim
name to the integer size used in the mock shapes, so callers can bind
those names into a shape_rules evaluation context. Returns None only
if ``signature.inputs`` is malformed.
"""
inputs = sig.get("inputs")
if not isinstance(inputs, dict) or not inputs:
return None
rules = sig.get("shape_rules") or []
rule_literals = _shape_eq_literals(rules)
ranks = _extract_shape_tuple_literals(rules)
# Extra rank hints from per-tensor shape declarations.
shape_decls: dict[str, list[str]] = {}
for name, attrs in inputs.items():
if not isinstance(attrs, dict):
continue
parts = _parse_shape_decl(attrs.get("shape", ""))
if parts is not None:
shape_decls[name] = parts
ranks.setdefault(name, len(parts))
shapes: dict[str, _MockShape] = {}
# Dim-name → size map shared across tensors. Distinct symbolic dims
# get distinct sizes (first-seen order) so cross-tensor equality
# rules do not spuriously pass on colliding mock sizes.
dim_sizes: dict[str, int] = {}
for _tname, parts in rule_literals:
for p in parts:
if _IDENT_RE.fullmatch(p) and p not in dim_sizes:
dim_sizes[p] = _MOCK_DIM_SIZE + len(dim_sizes)
# Also bind symbolic dims from input shape declarations, then from
# declared output shapes, so downstream rule / shape-decl checks
# resolve them against the same mock sizes.
for parts in shape_decls.values():
for p in parts:
if p not in dim_sizes:
dim_sizes[p] = _MOCK_DIM_SIZE + len(dim_sizes)
outputs_map = sig.get("outputs") or {}
if isinstance(outputs_map, dict):
for attrs in outputs_map.values():
if not isinstance(attrs, dict):
continue
out_parts = _parse_shape_decl(attrs.get("shape", ""))
if out_parts is None:
continue
for p in out_parts:
if p not in dim_sizes:
dim_sizes[p] = _MOCK_DIM_SIZE + len(dim_sizes)
for name in inputs:
if name in ranks:
# First matching rule literal for this tensor, if all parts
# are bare identifiers.
parts = next(
(ps for tn, ps in rule_literals if tn == name), None,
)
if parts is not None and all(
_IDENT_RE.fullmatch(p) for p in parts
):
shapes[name] = _MockShape(
dim_sizes.get(p, _MOCK_DIM_SIZE) for p in parts
)
continue
# Fallback: per-tensor shape declaration from signature.inputs.
if name in shape_decls:
shapes[name] = _MockShape(
dim_sizes.get(p, _MOCK_DIM_SIZE) for p in shape_decls[name]
)
continue
# Fallback: 2D shape
shapes[name] = _MockShape(
(_MOCK_DIM_SIZE, _MOCK_DIM_SIZE)
)
return shapes, dim_sizes
def _param_defaults(params: dict) -> dict:
"""Extract ``default`` values from a signature.params dict.
Parameters without a default are omitted.
"""
out: dict = {}
if not isinstance(params, dict):
return out
for pname, pattrs in params.items():
if isinstance(pattrs, dict) and "default" in pattrs:
out[pname] = pattrs["default"]
return out
def _static_dim_values(
sig: dict,
mock_shapes: dict[str, _MockShape],
param_defaults: dict,
) -> dict:
"""Resolve ``signature.static_dims`` to concrete integer values.
Each entry is declared as ``<name>: "<tensor>.shape[<axis>]"`` where
``<tensor>`` is an input and ``<axis>`` is either an integer literal
or a param name. Returns only successfully resolved entries
(malformed / out-of-range entries are silently skipped — the L0
schema check reports those). Used by parity mock_self builders so
methods consulting ``self.<static_dim_name>`` see the concrete size
carried by the synthetic inputs instead of raising AttributeError.
"""
out: dict = {}
sdims = sig.get("static_dims")
if not isinstance(sdims, dict):
return out
for dname, expr in sdims.items():
if not isinstance(expr, str):
continue
m = _STATIC_DIM_EXPR_RE.match(expr)
if m is None:
continue
tname, axis_ref = m.groups()
shape = mock_shapes.get(tname)
if shape is None:
continue
# Resolve axis: integer literal or param-name lookup.
if axis_ref.lstrip("-").isdigit():
axis = int(axis_ref)
elif axis_ref in param_defaults and isinstance(
param_defaults[axis_ref], int
):
axis = param_defaults[axis_ref]
else:
continue
try:
out[dname] = int(shape[axis])
except (IndexError, TypeError, ValueError):
continue
return out
def _class_overrides_method(cls: type, name: str) -> bool:
"""Return True when *cls* (or a non-Op ancestor) defines *name*.
We walk the MRO skipping the root ``Op`` base class; the goal is to
detect user-authored overrides, not the base no-op.
"""
from tileops.ops.op_base import Op as _OpBase # local to avoid top-level import cost
for base in cls.__mro__:
if base is _OpBase or base is object:
continue
if name in base.__dict__:
return True
return False
def _broadcast_shapes(*shapes: object) -> tuple:
"""Pure-Python equivalent of ``torch.broadcast_shapes``.
Shapes are right-aligned; each dimension must be equal, or one of
them must be 1 (or missing). Returns ``()`` when called with no
arguments.
Raises:
ValueError: If the shapes are not broadcast-compatible.
"""
if not shapes:
return ()
normalized = [tuple(int(d) for d in s) for s in shapes]
ndim = max((len(s) for s in normalized), default=0)
out: list[int] = []
for axis in range(ndim):
# Right-align: walk from the trailing dim back.
dim = 1
for s in normalized:
i = len(s) - ndim + axis
if i < 0:
# This shape has no entry at this axis (treat as 1).
continue
d = s[i]
if d == 1 or d == dim:
continue
if dim == 1:
dim = d
continue
raise ValueError(
f"shapes {shapes!r} are not broadcast-compatible at axis {axis}",
)
out.append(dim)
return tuple(out)
def _is_broadcastable_to(src: object, dst: object) -> bool:
"""Return True if ``src`` is broadcastable *to* ``dst`` (unidirectional).
Unlike the symmetric ``broadcast_shapes``, this predicate fixes the
destination shape: each ``src`` dim (right-aligned) must equal the
matching ``dst`` dim or be 1, and ``src`` may not have more
dimensions than ``dst``.
"""
src_t = tuple(int(d) for d in src)
dst_t = tuple(int(d) for d in dst)
if len(src_t) > len(dst_t):
return False
offset = len(dst_t) - len(src_t)
for i, s_dim in enumerate(src_t):
d_dim = dst_t[offset + i]
if s_dim == d_dim or s_dim == 1:
continue
return False
return True
# Safe builtins allowed in shape_rules eval — matches the R11 / R11a
# documented helper set (see docs/design/ops-design-reference.md); keep
# aligned with the manifest spec, since widening it changes the rule
# language. Python primitives, the pure-Python broadcasting helpers
# (validator stays torch-free), and the reduction-dim helpers from
# ``tileops.manifest.shape_rules`` all share one flat eval namespace,
# callable by bare name from any rule body.
#
# Built from an explicit (name, callable) list so a name collision
# raises at validator import time instead of silently shadowing a
# primitive via dict merge.
_SHAPE_RULE_BUILTIN_PAIRS = [
("len", len),
("isinstance", isinstance),
("int", int),
# ``float`` lets manifest rules spell sentinels like
# ``ord == float('inf')``. Add new callables only when an existing
# manifest rule needs them and the semantics are obviously bounded.
("float", float),
("tuple", tuple),
("list", list),
("type", type),
("all", all),
("any", any),
("range", range),
("set", set),
("abs", abs),
("min", min),
("max", max),
("broadcast_shapes", _broadcast_shapes),
("is_broadcastable_to", _is_broadcastable_to),
("dim_range_validity", dim_range_validity),
("dim_uniqueness", dim_uniqueness),
("reduced_axes", reduced_axes),
]
_SHAPE_RULE_BUILTINS: dict = {}
for _entry_name, _entry_fn in _SHAPE_RULE_BUILTIN_PAIRS:
if _entry_name in _SHAPE_RULE_BUILTINS:
raise RuntimeError(
f"shape_rule builtin name collision: {_entry_name!r} is "
f"registered twice. Two callables cannot share the same "
f"name in the rule eval scope; rename one or unify them."
)
_SHAPE_RULE_BUILTINS[_entry_name] = _entry_fn
def _eval_shape_rule(
rule: str, ctx: dict,
) -> tuple[bool, str | None]:
"""Evaluate a single shape_rule in *ctx*.
Returns (ok, failure_reason). ``ok=False`` with reason=None means the
rule evaluated to a falsy non-exception value; a non-None reason
indicates the rule could not be evaluated (treated as skipped, not a
parity error).
The eval globals expose the ``_SHAPE_RULE_BUILTINS`` helper set so
R11 / R11a-style rules can be evaluated against the mock context
instead of being silently skipped. Context names (inputs / outputs /
params) are injected into both eval globals and locals: comprehension
scopes only see globals, so rules like
``all(d % x.ndim in ... for d in dim)`` still resolve ``x`` / ``dim``.
"""
# Defense-in-depth: even though manifest content is trusted (PR review
# gates it), parse the rule first and reject any dunder attribute
# access. This closes the classic ``().__class__.__mro__[1].
# __subclasses__()`` sandbox-escape against the restricted builtins.
try:
tree = ast.parse(rule, mode="eval")
except SyntaxError as exc:
return False, f"eval error: SyntaxError: {exc}"
for node in ast.walk(tree):
if isinstance(node, ast.Attribute) and (
node.attr.startswith("__") or node.attr.endswith("__")
):
return False, (
f"eval error: dunder attribute access not permitted "
f"({node.attr!r})"
)
eval_globals = {"__builtins__": _SHAPE_RULE_BUILTINS}
eval_globals.update(ctx)
# A ctx key literally named ``__builtins__`` would overwrite the
# sandboxed mapping installed above and re-expose the unrestricted
# builtins; reinstate the sandbox after the update.
eval_globals["__builtins__"] = _SHAPE_RULE_BUILTINS
try:
result = eval(
rule, eval_globals, ctx,
)
except Exception as exc:
return False, f"eval error: {exc.__class__.__name__}: {exc}"
try:
return bool(result), None
except Exception as exc:
return False, f"non-boolean result: {exc}"
def _build_mock_self(
cls: type,
param_defaults: dict,
extra_attrs: dict | None = None,
) -> object:
"""Build a mock ``self`` instance without running ``__init__``.
Uses ``cls.__new__(cls)`` so methods and helpers on the MRO remain
reachable — a plain :class:`types.SimpleNamespace` cannot satisfy
methods that read class attributes during a parity probe. Each
``param_defaults`` entry (from ``signature.params``) is installed as
an instance attribute so ``self.<param>`` lookups resolve.
``extra_attrs`` carries probe-specific attributes installed last
(overriding same-named params): static_dims values resolved from the
synthetic mock inputs, and the dtype axis so ``self.dtype`` reflects
the candidate combo instead of the ``Op.dtype = None`` base default.
Falls back to SimpleNamespace if ``cls.__new__`` raises.
"""
merged: dict = dict(param_defaults)
if extra_attrs:
merged.update(extra_attrs)
try:
instance = cls.__new__(cls)
except Exception:
return types.SimpleNamespace(**merged)
for k, v in merged.items():
# __slots__ or read-only descriptors may reject setattr; ignore
# — parity check will surface any resulting AttributeError as a
# skip when the target method actually reads ``self.<k>``.
with contextlib.suppress(AttributeError, TypeError):
setattr(instance, k, v)
return instance
def check_l2_infer_parity(
op_name: str,
entry: dict,
cls: type | None,
*,
warnings: list[str] | None = None,
) -> list[str]:
"""L2 extension: ``_infer_output_shapes`` parity with ``shape_rules``.
Calls the Op class's ``_infer_output_shapes`` with concrete mock input
shapes (no tensor allocation, no kernel execution). Plugs the result
into a shape_rules evaluation context and verifies every rule holds.
A missing override is surfaced as a warning (no silent pass); a
body-level exception after successful argument binding is a hard L2
error (binding mismatches report separately as signature errors);
concrete disagreement — rule violations or mismatches against a
declared ``signature.outputs[*].shape`` — produces L2 errors.
"""
errors: list[str] = []
if cls is None:
return errors
err = _emit_to(errors, "shape", op_name)
warn = _emit_to(warnings, "shape", op_name)
sig = entry.get("signature", {})
rules = sig.get("shape_rules") or []
if not isinstance(rules, list):
rules = []
outputs_map = sig.get("outputs") or {}
declared_output_shapes: dict[str, list[str]] = {}
if isinstance(outputs_map, dict):
for oname, oattrs in outputs_map.items():
if not isinstance(oattrs, dict):
continue
parts = _parse_shape_decl(oattrs.get("shape", ""))
if parts is not None:
declared_output_shapes[oname] = parts
# Nothing to check: neither rules nor declared output shapes.
if not rules and not declared_output_shapes:
return errors
if not _class_overrides_method(cls, "_infer_output_shapes"):
warn(
"class does not override _infer_output_shapes — "
"manifest-derived method not yet generated; parity check "
"skipped. Demote the op to 'status: spec-only' if the method "
"genuinely cannot be exercised from the CPU validator."
)
return errors
infer_fn = getattr(cls, "_infer_output_shapes", None)
if infer_fn is None:
return errors
mock = _mock_input_shapes(sig)
if mock is None:
return errors
mock_shapes, dim_sizes = mock
params = sig.get("params") or {}
param_defaults = _param_defaults(params)
# Build a mock ``self`` via ``cls.__new__(cls)`` (see
# ``_build_mock_self``) enriched with static_dims values resolved
# against the synthetic mock inputs, so generated implementations
# consulting ``self.<dim>`` (e.g. ``self.N`` for
# ``static_dims: {N: x.shape[-1]}``) do not raise a spurious
# AttributeError and skip the check.
extra_attrs = _static_dim_values(sig, mock_shapes, param_defaults)
mock_self = _build_mock_self(cls, param_defaults, extra_attrs)
shape_kwargs = {f"{name}_shape": tuple(shape) for name, shape in mock_shapes.items()}
# First, validate the callable signature independently of the body: a
# TypeError from inspect.signature().bind is a genuine signature mismatch
# between the expected ``<input>_shape=`` kwargs and _infer_output_shapes.
# TypeErrors raised inside the body (e.g. arithmetic on None) must not be
# misreported as signature mismatch.
try:
inspect.signature(infer_fn).bind(mock_self, **shape_kwargs)
except TypeError as exc:
err(
f"_infer_output_shapes signature does not match manifest "
f"inputs (expected kwargs {sorted(shape_kwargs)}): {exc}"
)
return errors
except Exception as exc:
# signature() itself failed (e.g. builtin without introspection) —
# skip parity rather than fabricating a signature error.
warn(
f"_infer_output_shapes parity skipped — inspect.signature "
f"raised {exc.__class__.__name__}: {exc}"
)
return errors
try:
result = infer_fn(mock_self, **shape_kwargs)
except Exception as exc:
# Signature is valid but the body raised. A correct manifest-
# derived ``_infer_output_shapes`` must succeed on manifest-
# compatible mock inputs; treat any body-level exception as a
# hard L2 parity error.
err(
f"_infer_output_shapes raised {exc.__class__.__name__} "
f"under mock inputs {shape_kwargs}: {exc}"
)
return errors
if not isinstance(result, dict):
err(
f"_infer_output_shapes must return a dict "
f"(output_name -> shape), got {type(result).__name__}"
)
return errors
outputs = sig.get("outputs") or {}
for out_name in outputs:
if out_name not in result:
err(
f"_infer_output_shapes missing output {out_name!r} "
f"(declared in manifest)"
)
# Assemble evaluation context: symbolic dims + inputs + outputs +
# params. Symbolic dim names are bound first so param / tensor names
# later in the dict take precedence on any accidental collision.
ctx: dict = {}
ctx.update(dim_sizes)
ctx.update(param_defaults)
for name, shape in mock_shapes.items():
ctx[name] = _MockShape(shape)
# Output-only symbols (appearing only in declared output shapes) get
# their concrete sizes from ``_infer_output_shapes`` (possibly via a
# ``shape_rules`` formula like ``L_out == L_in - kW + 1``). Rebind
# them from the inferred ``result`` so a rule defining them checks
# the computed value, not a synthetic mock size — otherwise a wrong
# implementation would be misclassified as an input-only
# precondition and skipped. On conflicting rebindings prefer the
# first; the consistency check below flags the mismatch.
input_bound = _input_bound_symbols(sig)
output_only_symbols: set[str] = set()
output_only_rebindings: dict[str, int] = {}
for out_name, decl_parts in declared_output_shapes.items():
for p in decl_parts:
if p not in input_bound:
output_only_symbols.add(p)
if out_name not in result:
continue
try:
inferred_tuple = tuple(result[out_name])
except TypeError:
continue
if len(inferred_tuple) != len(decl_parts):
continue
for p, got in zip(decl_parts, inferred_tuple, strict=True):
if p in input_bound:
continue
if not isinstance(got, int):
continue
if p not in output_only_rebindings:
output_only_rebindings[p] = got
for p, v in output_only_rebindings.items():
ctx[p] = v
# Input-only context (no inferred outputs, no output-only symbols)
# detects rules that already fail on the mock inputs themselves —
# such rules encode input-only preconditions (e.g.
# ``weight.shape == (x.shape[dim],)``) that mock inputs may violate;
# a correct ``_infer_output_shapes`` must not be blamed for those.
# Output-only symbols are stripped so an output-dependent rule like
# ``L_out == L_in - kW + 1`` is never reachable via this path.
input_only_ctx: dict = {
k: v for k, v in ctx.items() if k not in output_only_symbols
}
for out_name, out_shape in result.items():
try:
ctx[out_name] = _MockShape(tuple(out_shape))
except TypeError:
err(
f"_infer_output_shapes returned non-iterable shape "
f"for {out_name!r}: {out_shape!r}"
)
output_names = set(result.keys()) | set(outputs.keys())
for i, rule in enumerate(rules):
if not isinstance(rule, str):
continue
ok, reason = _eval_shape_rule(rule, ctx)
if reason is not None:
# Could not evaluate this rule under the mock context; do not
# flag as parity mismatch.
warn(
f"shape_rules[{i}] could not be evaluated against mock "
f"inputs ({reason}); rule: {rule!r}"
)
continue
if not ok:
# Distinguish a genuine parity mismatch from a mock-input
# precondition violation: if the rule already fails with
# inputs only (and does not reference any declared output
# tensor name *or* any output-only symbol), the mock input
# shapes themselves violate the rule — skip with a warning
# instead of blaming _infer_output_shapes.
mentions_output = any(
re.search(rf"\b{re.escape(o)}\b", rule) for o in output_names
) or any(
re.search(rf"\b{re.escape(s)}\b", rule)
for s in output_only_symbols
)
if not mentions_output:
ok_inputs, reason_inputs = _eval_shape_rule(
rule, input_only_ctx,
)
if reason_inputs is None and not ok_inputs:
warn(
f"shape_rules[{i}] {rule!r} not satisfied by "
f"synthetic mock inputs {shape_kwargs}; parity "
f"check skipped (input-only precondition)"
)
continue
err(
f"_infer_output_shapes output violates shape_rules[{i}] "
f"{rule!r} under mock inputs {shape_kwargs} -> {result}"
)
# Compare inferred outputs against per-tensor declared shapes in
# signature.outputs[*].shape, independently of shape_rules (catches
# ops specified only via declared shape fields). Input-bound symbols
# carry a concrete mock size to echo back exactly; output-only
# symbols get rank + per-symbol consistency enforcement instead.
# Static-dim symbols resolve to concrete integers against the mock
# inputs (``extra_attrs`` above) and pin expected sizes exactly.
static_expected: dict[str, int] = {
name: int(val) for name, val in extra_attrs.items()
if isinstance(val, int) and not isinstance(val, bool)
}
# Params with a concrete integer ``default`` are also compile-time
# known and pin declared-output-shape dims with the same authority as
# ``static_dims``. Params without a default (supplied at op
# construction, unknown to the validator) are skipped; non-int
# defaults (e.g. ``list[int]``) cannot pin a scalar dim position.
for pname, pdefault in param_defaults.items():
if pname in static_expected:
continue # static_dims wins — it is the declared source of truth.
if isinstance(pdefault, bool):
continue
if isinstance(pdefault, int):
static_expected[pname] = int(pdefault)
output_only_seen: dict[str, int] = {}
for out_name, decl_parts in declared_output_shapes.items():
if out_name not in result:
continue
try:
inferred = tuple(result[out_name])
except TypeError:
continue # already reported above
if len(inferred) != len(decl_parts):
err(
f"_infer_output_shapes output {out_name!r} rank "
f"{len(inferred)} disagrees with declared shape "
f"{decl_parts} (rank {len(decl_parts)}) under mock "
f"inputs {shape_kwargs} -> {inferred}"
)
continue
for idx, (p, got) in enumerate(zip(decl_parts, inferred, strict=True)):
if p in input_bound or p in static_expected:
# Input-bound or static-dim symbol: concrete size is
# pinned by mock inputs (or by the static_dims
# expression resolved against them) and must match
# exactly.
expected = static_expected.get(
p, dim_sizes.get(p, _MOCK_DIM_SIZE)
)
if got != expected:
err(
f"_infer_output_shapes output {out_name!r} "
f"dim[{idx}]={got} disagrees with declared "
f"{p}={expected} under mock inputs "
f"{shape_kwargs} -> {inferred}"
)
else:
# Output-only symbol: value is derived by
# _infer_output_shapes (and possibly a shape_rules
# formula). Only enforce consistency — the same symbol
# must resolve to the same concrete size everywhere it
# appears across all declared outputs.
prev = output_only_seen.get(p)
if prev is None:
output_only_seen[p] = got
elif prev != got:
err(
f"_infer_output_shapes output {out_name!r} binds "
f"output-only symbol {p!r} to {got} but earlier "
f"output bound it to {prev} (inconsistent under "
f"mock inputs {shape_kwargs})"
)
return errors
# ---------------------------------------------------------------------------
# dtype parity: _validate_dtypes vs dtype_combos / dtype unions (L3 extension)
# ---------------------------------------------------------------------------
def _expand_promote_int_to_float(ref_options: list[str]) -> list[str]:
"""Resolve ``promote_int_to_float(ref)`` against ``ref``'s dtype options.
Each integral token in ``ref_options`` (uint8 / int8 / int16 / int32 /
int64) maps to ``float32``; non-integral tokens pass through unchanged.
Result is de-duplicated, preserving first-seen order.
"""
out: list[str] = []
seen: set[str] = set()
for opt in ref_options:
target = _PROMOTE_TARGET_DTYPE if opt in _PROMOTE_INT_DTYPES else opt
if target not in seen:
seen.add(target)
out.append(target)
return out
def _dtype_options_for_tensor(
tname: str, dtype_str: str, resolved: dict[str, list[str]],
) -> list[str] | None:
"""Expand a dtype expression into concrete torch dtype names.
``same_as(ref)`` resolves to whatever *ref* has already been resolved
to in the *resolved* map. Declaration order is irrelevant: callers
(``_resolve_tensor_dtype_options``) iterate to a fixpoint, retrying
tensors whose ``same_as(ref)`` targets unresolved refs until every
tensor resolves or no progress is made. Returns None when the
expression cannot be resolved in the current pass (caller decides
whether that is a temporary state inside the fixpoint loop or a
permanent failure).
"""
out: list[str] = []
for tok in _parse_dtype_expr(dtype_str):
m = _SAME_AS_RE.match(tok)
if m:
ref = m.group(1)
# Unresolved reference — propagate failure per docstring
# contract. Returning [] here would silently disable parity.
if ref not in resolved:
return None
out.extend(resolved[ref])
continue
m = _PROMOTE_INT_TO_FLOAT_RE.match(tok)
if m:
ref = m.group(1)
if ref not in resolved:
return None
out.extend(_expand_promote_int_to_float(resolved[ref]))
continue
if tok in _TORCH_DTYPES:
out.append(tok)
else:
return None
# De-dup preserving order
seen: set[str] = set()
uniq: list[str] = []
for t in out:
if t not in seen:
seen.add(t)
uniq.append(t)
return uniq
def _resolve_tensor_dtype_options(
sig: dict,
) -> dict[str, list[str]] | None:
"""Return dtype options for every declared tensor (inputs + outputs).
Resolves ``same_as`` references to a fixpoint: declaration order is
irrelevant, so ``x: same_as(y)`` declared before ``y: float16`` still
resolves. Returns None only if some tensor's expression is genuinely
unresolvable (unknown token, dangling ``same_as`` reference, or a
``same_as`` cycle).
"""
# Collect every tensor's raw dtype string first, so iteration order
# cannot affect the result.
pending: dict[str, str] = {}
for group in ("inputs", "outputs"):
tensors = sig.get(group) or {}
if not isinstance(tensors, dict):
continue
for tname, attrs in tensors.items():
if not isinstance(attrs, dict):
return None
pending[tname] = attrs.get("dtype", "")
resolved: dict[str, list[str]] = {}
# Iterate to fixpoint: each pass resolves every tensor whose
# dependencies are already known. Bound the loop by len(pending) + 1
# — any longer progression implies a cycle (no new resolutions).
for _ in range(len(pending) + 1):
made_progress = False
for tname, dtype_str in list(pending.items()):
opts = _dtype_options_for_tensor(tname, dtype_str, resolved)
if opts is None:
continue
resolved[tname] = opts
del pending[tname]
made_progress = True
if not pending:
return resolved
if not made_progress:
# Remaining tensors reference something unresolvable (unknown
# dtype name, dangling ref, or a same_as cycle). Propagate
# failure per docstring contract.
return None
return resolved if not pending else None
def _primary_dtype_input(
sig: dict, forward_inputs: list[str],
) -> str | None:
"""Return the first input whose dtype is not bound by ``same_as(ref)``.
The returned input is used to stamp ``self.dtype`` on the mock self
for dtype parity. Same_as-bound inputs are skipped because their
dtype is derivative: their dtype follows ``ref``, and
manifest-derived ``_validate_dtypes`` implementations typically
compare the op's ``self.dtype`` against the unbound primary input.
"""
inputs = sig.get("inputs") or {}
if not isinstance(inputs, dict):
return None
for name in forward_inputs:
attrs = inputs.get(name)
if not isinstance(attrs, dict):
continue
dstr = attrs.get("dtype", "")
tokens = _parse_dtype_expr(dstr)
if len(tokens) == 1 and _SAME_AS_RE.match(tokens[0]):
continue
return name
# Fallback: no fully-free input; use the first declared input even
# if it's same_as-bound, so ``self.dtype`` is at least non-None.
return forward_inputs[0] if forward_inputs else None
def _make_mock_tensor(dtype_name: str):
"""Build a 0-sized torch tensor of the named dtype (CPU).
Uses 0 elements so allocation is cheap and no GPU is touched.
"""
import torch
torch_dtype = getattr(torch, dtype_name, None)
if torch_dtype is None:
return None
try:
return torch.empty(0, dtype=torch_dtype, device="cpu")
except (RuntimeError, TypeError):
return None
def _combo_accepted(
cls: type, forward_inputs: list[str], combo: dict[str, str],
param_defaults: dict, sig: dict | None = None,
self_dtype_name: str | None = None,
) -> tuple[bool, str | None]:
"""Invoke ``cls._validate_dtypes`` on a mock-self with *combo*.
Returns (accepted, error_reason). ``accepted=False`` with
reason=None means the op raised during validation (rejected);
reason!=None indicates the call could not be performed (skip).
When ``sig`` is provided, the mock-self is enriched with static_dims
values resolved against synthetic mock inputs and with ``self.dtype``
bound to the candidate's dtype axis — both commonly consulted by
generated ``_validate_dtypes`` implementations (``if x.dtype !=
self.dtype: raise``); without them the probe would spuriously reject
listed combos. ``self_dtype_name`` pins ``mock_self.dtype``
explicitly (out-of-union probes keep the op's configured dtype at a
valid baseline while mutating the input tensor's dtype); when
omitted it follows the combo entry for the first non-same_as-bound
input.
"""
validate_fn = getattr(cls, "_validate_dtypes", None)
if validate_fn is None:
return False, "no _validate_dtypes"
tensors: dict = {}
for name in forward_inputs:
dtype_name = combo.get(name)
if dtype_name is None:
return False, f"combo missing input {name!r}"
t = _make_mock_tensor(dtype_name)
if t is None:
return False, f"cannot build mock tensor for dtype {dtype_name!r}"
tensors[name] = t
# Build mock self via ``cls.__new__(cls)`` so _validate_dtypes
# methods that consult other class helpers or instance attributes
# (beyond manifest params) do not falsely raise AttributeError.
extra_attrs: dict = {}
if sig is not None:
mock = _mock_input_shapes(sig)
if mock is not None:
mock_shapes, _ = mock
extra_attrs.update(
_static_dim_values(sig, mock_shapes, param_defaults)
)
# Install self.dtype mirroring the manifest convention: the op's
# dtype attribute tracks the candidate's primary dtype (first
# non-same_as-bound input by default) unless an explicit
# ``self_dtype_name`` override is supplied (out-of-union probes
# pin the baseline valid dtype so only the input tensor's dtype
# deviates). A manifest-derived _validate_dtypes that compares
# ``x.dtype != self.dtype`` then sees a real torch.dtype instead
# of the base-class ``None``.
if self_dtype_name is not None:
override_t = _make_mock_tensor(self_dtype_name)
if override_t is not None:
extra_attrs["dtype"] = override_t.dtype
else:
primary = _primary_dtype_input(sig, forward_inputs)
if primary is not None and primary in tensors:
extra_attrs["dtype"] = tensors[primary].dtype
mock_self = _build_mock_self(cls, param_defaults, extra_attrs)
# Pre-bind the callable signature so only genuine signature mismatches
# surface as ``TypeError: ...``. TypeError raised from inside the body
# (e.g. comparing incompatible torch dtypes) is a legitimate rejection
# and must not be misreported as a signature mismatch.
try:
inspect.signature(validate_fn).bind(mock_self, **tensors)
except TypeError as exc:
return False, f"TypeError: {exc}"
except Exception as exc:
# inspect.signature itself failed to introspect — treat as a
# validator-side skip (not an op-side bug). Tagged distinctly
# from body-level unexpected exceptions so callers can enforce
# policy differences.
return False, f"introspect-failed {exc.__class__.__name__}: {exc}"
try:
validate_fn(mock_self, **tensors)
except (ValueError, TypeError):
# Body-level rejection: either an explicit ValueError or a
# TypeError arising from dtype comparisons. Both are legitimate
# rejections once the signature has been validated above.
return False, None
except Exception as exc:
# Body raised a non-ValueError/TypeError exception. This is a
# genuine implementation bug (a correct manifest-derived
# ``_validate_dtypes`` must either accept or raise
# ValueError/TypeError, never e.g. RuntimeError). Callers
# enforce this as a hard L3 parity error unless the entry opts
# without opt-out (parity is unconditional for implemented ops).
return False, f"unexpected {exc.__class__.__name__}: {exc}"
return True, None
def _emit_to(sink, tag: str, op_name: str):
"""Return an emitter appending ``[tag] op_name: msg`` strings to *sink*.
A ``None`` sink yields a no-op emitter so callers can bind a warning
emitter without guarding every call site.
"""
if sink is None:
return lambda msg: None
prefix = f"[{tag}] {op_name}: "
return lambda msg: sink.append(prefix + msg)
# ``_combo_accepted`` reason prefixes → dispatch kinds. Callers branch on
# the kind instead of re-matching prefixes at every probe site.
_REASON_KINDS = (
("TypeError", "signature"),
("introspect-failed", "introspect"),
("unexpected", "unexpected"),
("cannot build mock tensor", "no-mock"),
("combo missing input", "missing-input"),
)
def _probe_reason_kind(reason: str | None) -> str | None:
"""Classify a ``_combo_accepted`` reason string by its prefix.
Returns None for a clean probe (reason is None); ``"other"`` for an
unrecognized reason (no caller acts on it, matching the previous
per-site prefix cascades).
"""
if reason is None:
return None
for prefix, kind in _REASON_KINDS:
if reason.startswith(prefix):
return kind
return "other"
def _probe_out_of_union(
op_name: str,
cls: type,
sig: dict,
forward_inputs: list[str],
baseline: dict[str, str],
dtype_options: dict[str, list[str]],
param_defaults: dict,
errors: list[str],
warnings: list[str] | None,
) -> None:
"""Out-of-union negative probe (rejection side), shared by both branches.
Starting from *baseline* (a combination known to be accepted),
substitutes an out-of-union sentinel for each non-same_as-bound input
in turn; every candidate must be rejected. ``self.dtype`` stays
pinned to the baseline's primary dtype so only the input tensor's
dtype deviates (a generated ``x.dtype != self.dtype`` check would
otherwise spuriously pass). same_as-bound tensors follow their ref
via propagation. Bounded by ``_MAX_DTYPE_COMBOS``.
"""
err = _emit_to(errors, "dtype", op_name)
warn = _emit_to(warnings, "dtype", op_name)
same_as_refs = _same_as_refs(sig)
baseline_primary = _primary_dtype_input(sig, forward_inputs)
baseline_self_dtype = (
baseline.get(baseline_primary)
if baseline_primary is not None else None
)
probed = 0
for target in forward_inputs:
if target in same_as_refs:
continue
declared = set(dtype_options.get(target, []))
out_of_union = _out_of_union_candidates(declared)
if not out_of_union:
# Declared union covers the entire torch dtype set (wildly
# permissive spec) — no rejection candidate exists. Warn
# instead of vacuously passing.
warn(
f"out-of-union probe skipped for input {target!r}"
f"declared dtype union covers the entire torch dtype "
f"set; rejection side cannot be exercised"
)
continue
for bad_dtype in out_of_union:
if probed >= _MAX_DTYPE_COMBOS:
break
probed += 1
candidate = dict(baseline)
candidate[target] = bad_dtype
for tname, ref in same_as_refs.items():
if ref == target and tname in candidate:
candidate[tname] = bad_dtype
accepted, reason = _combo_accepted(
cls, forward_inputs, candidate, param_defaults,
sig=sig, self_dtype_name=baseline_self_dtype,
)
if _probe_reason_kind(reason) == "unexpected":
err(
f"_validate_dtypes raised unexpected exception on "
f"out-of-union probe {candidate!r}{reason}"
)
continue
if accepted:
err(
f"_validate_dtypes accepts out-of-union dtype "
f"{candidate!r} (input {target!r} declared "
f"{sorted(declared)})"
)
if probed >= _MAX_DTYPE_COMBOS:
break
def check_l3_validate_dtypes_parity(
op_name: str,
entry: dict,
cls: type | None,
*,
warnings: list[str] | None = None,
) -> list[str]:
"""L3 extension: ``_validate_dtypes`` parity with manifest dtypes.
With ``dtype_combos`` declared: iterate all combos and verify the
method accepts each listed combo and rejects at least one non-listed
combination drawn from the same input dtype universe.
Without ``dtype_combos``: verify every combination in the Cartesian
product of each input's declared dtype union is accepted.
For ops whose class does not override ``_validate_dtypes``, emits a
warning reporting the missing manifest-derived method (no silent
pass).
"""
errors: list[str] = []
if cls is None:
return errors
err = _emit_to(errors, "dtype", op_name)
warn = _emit_to(warnings, "dtype", op_name)
if not _class_overrides_method(cls, "_validate_dtypes"):
warn(
"class does not override _validate_dtypes — manifest-derived "
"method not yet generated; parity check skipped. Demote the "
"op to 'status: spec-only' if the method genuinely cannot be "
"exercised from the CPU validator."
)
return errors
sig = entry.get("signature", {})
inputs = sig.get("inputs") or {}
if not isinstance(inputs, dict) or not inputs:
return errors
# Only pass tensors corresponding to manifest inputs (forward args).
forward_inputs = list(inputs.keys())
params = sig.get("params") or {}
param_defaults = _param_defaults(params)
dtype_options = _resolve_tensor_dtype_options(sig)
if dtype_options is None:
# L3 dtype check will already have reported unresolved tokens.
return errors
dtype_combos = sig.get("dtype_combos")
if isinstance(dtype_combos, list) and dtype_combos:
# Combo-data validity: surface invalid entries as hard L3 errors
# so downstream parity probing does not run on junk data. The
# same check also runs unconditionally in ``check_l3`` — the
# driver dedupes error strings so users see each message once.
combo_validation_errors = check_l3_dtype_combos_data(op_name, sig)
if combo_validation_errors:
errors.extend(combo_validation_errors)
return errors
# Expand ``same_as(ref)`` in combo values to a concrete dtype
# before parity probing: ``_combo_accepted`` expects literal
# torch dtype names. Per R3 + R4 identity is already enforced
# (``_check_dtype_combos_same_as_identity``), so each
# ``same_as(ref)`` resolves to the ref's dtype in the same row.
expanded_combos: list[dict[str, str]] = []
for combo in dtype_combos:
if not isinstance(combo, dict):
expanded_combos.append({})
continue
expanded: dict[str, str] = {}
for key, val in combo.items():
if isinstance(val, str):
m = _SAME_AS_RE.match(val.strip())
if m:
ref = m.group(1)
ref_val = combo.get(ref)
expanded[key] = ref_val if isinstance(ref_val, str) else val
continue
expanded[key] = val
expanded_combos.append(expanded)
dtype_combos = expanded_combos
# Each listed combo should be accepted.
for i, combo in enumerate(dtype_combos):
if not isinstance(combo, dict):
continue
accepted, reason = _combo_accepted(
cls, forward_inputs, combo, param_defaults, sig=sig,
)
kind = _probe_reason_kind(reason)
if kind == "signature":
err(
f"_validate_dtypes signature does not match manifest "
f"inputs (expected kwargs {sorted(forward_inputs)}): "
f"{reason}"
)
return errors
if kind in ("introspect", "no-mock"):
# Validator-side limitation (inspect.signature failed or
# the local torch build lacks the dtype) — skip with a
# parity-skip warning, not an op-side error.
warn(
f"_validate_dtypes parity skipped for "
f"dtype_combos[{i}] — {reason}"
)
continue
if kind == "unexpected":
# Body-level exception that is not ValueError / TypeError
# — a real implementation bug. Hard L3 parity error.
err(
f"_validate_dtypes raised unexpected exception on "
f"dtype_combos[{i}] {combo!r}{reason}"
)
continue
if kind == "missing-input":
# Manifest error: combo doesn't specify a dtype for every
# declared input. Parity error, not a rejection.
err(f"dtype_combos[{i}] {combo!r} {reason}")
continue
if not accepted:
err(
f"_validate_dtypes rejects dtype_combos[{i}] "
f"{combo!r} listed in manifest"
)
# Every non-listed combo drawn from the inputs' union must be
# rejected. Enumerate the full Cartesian product and report any
# non-listed combo that ``_validate_dtypes`` accepts. Breaking on
# the first rejection would miss a later accepted combo.
input_options: list[list[str]] = [
dtype_options.get(name, []) for name in forward_inputs
]
product_size = 1
for opts in input_options:
product_size *= max(len(opts), 1)
if product_size > _MAX_DTYPE_COMBOS:
warn(
f"Cartesian product of dtype options ({product_size}) "
f"exceeds _MAX_DTYPE_COMBOS={_MAX_DTYPE_COMBOS}; "
f"non-listed rejection check skipped "
f"({len(forward_inputs)} inputs × options "
f"{[len(o) for o in input_options]})"
)
return errors
listed_combo_keys = {
tuple(combo.get(n) for n in forward_inputs)
for combo in dtype_combos if isinstance(combo, dict)
}
rejected_at_least_one = False
checked_any = False
for tup in itertools.product(*input_options):
if tup in listed_combo_keys:
continue
candidate = dict(zip(forward_inputs, tup, strict=True))
checked_any = True
accepted, reason = _combo_accepted(
cls, forward_inputs, candidate, param_defaults, sig=sig,
)
kind = _probe_reason_kind(reason)
if kind in ("introspect", "signature"):
continue
if kind == "unexpected":
err(
f"_validate_dtypes raised unexpected exception on "
f"non-listed combo {candidate!r}{reason}"
)
continue
if not accepted:
rejected_at_least_one = True
continue
# Accepted non-listed combo — parity violation. Keep scanning
# so multiple such combos are all surfaced in a single run.
err(
f"_validate_dtypes accepts non-listed combo "
f"{candidate!r} (not in dtype_combos)"
)
# Out-of-union negative probe: baseline is the first listed combo
# covering every input (known to be accepted).
baseline_combo: dict[str, str] | None = None
for c in dtype_combos:
if isinstance(c, dict) and all(
n in c for n in forward_inputs
):
baseline_combo = dict(c)
break
if baseline_combo is not None:
_probe_out_of_union(
op_name, cls, sig, forward_inputs, baseline_combo,
dtype_options, param_defaults, errors, warnings,
)
if not errors:
if not checked_any:
# No non-listed combo exists in the Cartesian product —
# dtype_combos already enumerates every reachable tuple.
warn(
"could not find a non-listed combo to exercise "
"rejection (dtype_combos exhausts the union)"
)
elif not rejected_at_least_one:
# Non-listed combos were tried but none were rejected —
# either _validate_dtypes is too lax or every non-listed
# candidate was skipped (unexpected/TypeError).
warn(
"no non-listed dtype combo was rejected by "
"_validate_dtypes; parity coverage may be incomplete"
)
else:
# No dtype_combos — verify every Cartesian combination is accepted.
input_options = [
dtype_options.get(name, []) for name in forward_inputs
]
if not all(input_options):
return errors
product_size = 1
for opts in input_options:
product_size *= len(opts)
if product_size > _MAX_DTYPE_COMBOS:
warn(
f"Cartesian product of dtype options ({product_size}) "
f"exceeds _MAX_DTYPE_COMBOS={_MAX_DTYPE_COMBOS}; parity "
f"check skipped ({len(forward_inputs)} inputs × options "
f"{[len(o) for o in input_options]})"
)
return errors
for tup in itertools.product(*input_options):
# Only keep combos that honour same_as identity constraints:
# when tensor T has dtype same_as(R), T and R must match.
candidate = dict(zip(forward_inputs, tup, strict=True))
if not _honours_same_as(sig, candidate):
continue
accepted, reason = _combo_accepted(
cls, forward_inputs, candidate, param_defaults, sig=sig,
)
kind = _probe_reason_kind(reason)
if kind == "signature":
# Signature mismatch between manifest inputs and the op's
# _validate_dtypes — surface as a parity error (analogous
# to the L2 _infer_output_shapes signature check).
err(
f"_validate_dtypes signature does not match manifest "
f"inputs (expected kwargs {sorted(forward_inputs)}): "
f"{reason}"
)
return errors
if kind == "introspect":
warn(
f"_validate_dtypes parity skipped for combo "
f"{candidate!r}{reason}"
)
continue
if kind == "unexpected":
# Body-level unexpected exception — hard error.
# See ``_combo_accepted`` docstring.
err(
f"_validate_dtypes raised unexpected exception on "
f"combo {candidate!r}{reason}"
)
continue
if not accepted:
err(
f"_validate_dtypes rejects valid combo {candidate!r} "
f"drawn from manifest dtype unions"
)
# Out-of-union negative probe: baseline is the first
# same_as-honouring candidate from the union.
baseline: dict[str, str] | None = None
for tup in itertools.product(*input_options):
cand = dict(zip(forward_inputs, tup, strict=True))
if _honours_same_as(sig, cand):
baseline = cand
break
if baseline is not None:
_probe_out_of_union(
op_name, cls, sig, forward_inputs, baseline,
dtype_options, param_defaults, errors, warnings,
)
# --- same_as identity negative probe (R3 rejection side) -------
# For each same_as(ref) input, build a candidate where that
# tensor's dtype differs from its ref and assert rejection.
# Complements (does not replace) the ``_honours_same_as`` skip
# in the union-iteration loop above.
if baseline is not None:
same_as_refs = _same_as_refs(sig)
probed_same_as = 0
for tname, ref in same_as_refs.items():
if probed_same_as >= _MAX_DTYPE_COMBOS:
break
if tname not in baseline or ref not in baseline:
continue
ref_dtype = baseline[ref]
# Pick any dtype different from the ref. Prefer values in
# the tensor's own declared options (so a pure same_as
# check is the only violation); fall back to sentinels.
own_opts = dtype_options.get(tname, [])
alt_dtypes = [d for d in own_opts if d != ref_dtype]
if not alt_dtypes:
alt_dtypes = [
d for d in _DTYPE_SENTINELS if d != ref_dtype
]
for alt in alt_dtypes[:1]: # one probe per same_as edge
probed_same_as += 1
candidate = dict(baseline)
candidate[tname] = alt
accepted, reason = _combo_accepted(
cls, forward_inputs, candidate, param_defaults, sig=sig,
)
kind = _probe_reason_kind(reason)
if kind in ("introspect", "signature"):
continue
if kind == "unexpected":
err(
f"_validate_dtypes raised unexpected "
f"exception on same_as probe {candidate!r} "
f"{reason}"
)
continue
if accepted:
err(
f"_validate_dtypes accepts same_as violation "
f"{candidate!r} (input {tname!r} declared "
f"same_as({ref}))"
)
return errors
def _same_as_refs(sig: dict) -> dict[str, str]:
"""Return ``{tensor: ref}`` for every pure ``same_as(ref)`` input.
Used by the negative-probe pass in
:func:`check_l3_validate_dtypes_parity` to identify edges that must
be exercised against a mismatched dtype and to propagate out-of-union
substitutions to dependent tensors.
"""
refs: dict[str, str] = {}
inputs = sig.get("inputs") or {}
if not isinstance(inputs, dict):
return refs
for tname, attrs in inputs.items():
if not isinstance(attrs, dict):
continue
dstr = attrs.get("dtype", "")
tokens = _parse_dtype_expr(dstr)
if len(tokens) == 1:
m = _SAME_AS_RE.match(tokens[0])
if m:
refs[tname] = m.group(1)
return refs
def _honours_same_as(sig: dict, candidate: dict[str, str]) -> bool:
"""Return True when *candidate* satisfies same_as identity (R3)."""
inputs = sig.get("inputs") or {}
if not isinstance(inputs, dict):
return True
for tname, attrs in inputs.items():
if not isinstance(attrs, dict):
continue
dstr = attrs.get("dtype", "")
tokens = _parse_dtype_expr(dstr)
if len(tokens) == 1:
m = _SAME_AS_RE.match(tokens[0])
if m:
ref = m.group(1)
if ref in candidate and candidate.get(tname) != candidate[ref]:
return False
return True
# ---------------------------------------------------------------------------
# bench: benchmark file uses manifest workloads
# ---------------------------------------------------------------------------
def _resolve_constant_str_bindings(tree: ast.Module) -> dict[str, str]:
"""Collect simple module-level string constants: NAME = 'value'."""
bindings: dict[str, str] = {}
for node in tree.body:
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
continue
target = node.targets[0]
if not isinstance(target, ast.Name):
continue
if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
bindings[target.id] = node.value.value
return bindings
def _call_uses_expected_op_name(
call: ast.Call, expected_op_name: str, bindings: dict[str, str],
) -> bool:
"""Return True when call(arg0, ...) uses the expected op name."""
if not call.args:
return False
first_arg = call.args[0]
if isinstance(first_arg, ast.Constant) and isinstance(first_arg.value, str):
return first_arg.value == expected_op_name
if isinstance(first_arg, ast.Name):
return bindings.get(first_arg.id) == expected_op_name
return False
def _ast_manifest_call_usage(
tree: ast.Module,
op_name: str,
target_names: set[str],
) -> dict[str, bool]:
"""Check whether target functions are imported and called with this op name.
Recognises the direct pattern (``load_workloads`` from
``tileops.manifest`` called with the op name + ``op.eval_roofline()``)
and the indirect one (``workloads_to_params`` / ``ManifestBenchmark``
from ``benchmarks.benchmark_base``, op name as first argument).
"""
# Maps from the indirect helper name → the direct target it satisfies.
_INDIRECT_EQUIV: dict[str, str] = {
"workloads_to_params": "load_workloads",
"ManifestBenchmark": "eval_roofline",
}
imported: set[str] = set()
matched_calls: set[str] = set()
bindings = _resolve_constant_str_bindings(tree)
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
if node.module == "tileops.manifest" and node.names:
for alias in node.names:
if alias.name in target_names:
imported.add(alias.name)
# Indirect helpers live in benchmarks.benchmark_base.
if node.module == "benchmarks.benchmark_base" and node.names:
for alias in node.names:
equiv = _INDIRECT_EQUIV.get(alias.name)
if equiv and equiv in target_names:
imported.add(equiv)
elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
func_name = node.func.id
# Direct call (load_workloads).
if func_name in target_names and _call_uses_expected_op_name(
node, op_name, bindings,
):
matched_calls.add(func_name)
# Indirect call (workloads_to_params / ManifestBenchmark).
equiv = _INDIRECT_EQUIV.get(func_name)
if equiv and equiv in target_names and _call_uses_expected_op_name(
node, op_name, bindings,
):
matched_calls.add(equiv)
elif (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "eval_roofline"
and "eval_roofline" in target_names
):
imported.add("eval_roofline")
matched_calls.add("eval_roofline")
return {name: (name in imported and name in matched_calls) for name in target_names}
def check_l4_benchmark(
op_name: str, bench_path: str, repo_root: Path,
) -> list[str]:
"""Check that the benchmark file uses manifest workloads and op roofline.
Uses Python AST parsing (no execution) to verify actual import and usage,
rather than raw substring matching which can be fooled by comments.
Returns a list of hard validation errors.
"""
errors: list[str] = []
full_path = Path(bench_path)
if not full_path.is_absolute():
full_path = repo_root / bench_path
if not full_path.is_file():
errors.append(f"[bench] {op_name}: bench file not found: {bench_path}")
return errors
content = full_path.read_text(encoding="utf-8")
try:
tree = ast.parse(content, filename=bench_path)
except SyntaxError as exc:
errors.append(
f"[bench] {op_name}: bench file {bench_path} has syntax error: {exc}"
)
return errors
targets = {"load_workloads", "eval_roofline"}
usage = _ast_manifest_call_usage(tree, op_name, targets)
if not usage["load_workloads"]:
errors.append(
f"[bench] {op_name}: bench file {bench_path} must import "
f"load_workloads from tileops.manifest and call it with op name {op_name!r}"
)
if not usage["eval_roofline"]:
errors.append(
f"[bench] {op_name}: bench file {bench_path} must call "
"eval_roofline() on an Op instance or use ManifestBenchmark "
f"with op name {op_name!r}"
)
return errors
# ---------------------------------------------------------------------------
# Strict parity checks (C1-C7) for status: implemented ops
# ---------------------------------------------------------------------------
#
# C1 (shape parity) and C2 (dtype parity) are implemented by
# ``check_l2_infer_parity`` and ``check_l3_validate_dtypes_parity``
# respectively; the orchestrator wires those in directly.
#
# This block adds the four remaining contracts:
#
# C3 — ctor signature parity (defaults + kw-only beyond L1 names)
# C4 — forward signature parity (positional names match
# ``signature.inputs`` order; complements L1)
# C5 — ``dispatch_kernel`` invariant (sentinel kernel pass-through)
# C6 — ``_validate_dtypes`` is not the ``Op`` base stub
# C7 — ``eval_roofline`` is not the ``Op`` base stub
# Infrastructure params that the validator filters out of ctor parity:
# they never appear in manifest ``signature.params`` but are part of the
# Op interface contract.
_CTOR_INFRA_PARAMS = frozenset({"self", "kernel_map", "tune"})
# Ctor parameter names whose mechanism has been removed from the codebase
# (e.g. elementwise ``strategy``, folded into the kernel config dict). A
# retired name appearing as a code-only ``__init__`` parameter is an error
# regardless of family: unlike the general code-only-extras rule (deferred
# in ``check_c3_ctor_signature_parity``), retired names need no
# protocol-derived allowed set — they are illegal by construction unless
# the manifest explicitly reintroduces them under ``signature.params``.
_CTOR_RETIRED_PARAMS = frozenset({"strategy"})
# Sentinel for "manifest did not declare this attribute" — distinct from
# any legitimate manifest value (including the string "REQUIRED" used to
# explicitly mark a parameter as required).
_MISSING = object()
def _init_calls_dispatch_kernel(cls: type) -> "bool | None":
"""Return True if ``cls.__init__`` body either calls
``self.dispatch_kernel(...)`` directly or delegates via
``super().__init__(...)``.
``None`` means the source could not be parsed (built-in /
dynamically generated ``__init__``); callers treat that as
inconclusive. Pure-AST inspection per the Slot S13 contract in
``docs/design/ops-design-reference.md``; ``super().__init__(...)``
satisfies S13 transitively. No runtime construction; no GPU.
"""
try:
lines, start_lineno = inspect.getsourcelines(cls.__init__)
except (OSError, TypeError):
return None
try:
tree = ast.parse(textwrap.dedent("".join(lines)))
except SyntaxError:
return None
code = getattr(cls.__init__, "__code__", None)
target_lineno = getattr(code, "co_firstlineno", None)
init_node = None
for node in ast.walk(tree):
if not isinstance(node, ast.FunctionDef) or node.name != "__init__":
continue
if target_lineno is None or start_lineno + node.lineno - 1 == target_lineno:
init_node = node
break
if init_node is None:
return None
for node in ast.walk(init_node):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
continue
# self.dispatch_kernel(...)
if (
node.func.attr == "dispatch_kernel"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "self"
):
return True
# super().__init__(...): delegates to the parent's body which is
# expected to honor S13 itself.
if (
node.func.attr == "__init__"
and isinstance(node.func.value, ast.Call)
and isinstance(node.func.value.func, ast.Name)
and node.func.value.func.id == "super"
):
return True
return False
def check_c3_ctor_signature_parity(
op_name: str,
entry: dict,
cls: type | None,
*,
warnings: list[str] | None = None,
) -> list[str]:
"""C3: ctor parameters match manifest ``signature.params``.
Compares the names, defaults, and keyword-only flag of every
``__init__`` parameter (after stripping ``_CTOR_INFRA_PARAMS``)
against ``signature.params``. L1 already covers presence; this
check adds the default-value and kw-only contracts.
"""
errors: list[str] = []
if cls is None:
return errors
err = _emit_to(errors, "ctor", op_name)
sig = entry.get("signature", {})
manifest_params = sig.get("params") or {}
if not isinstance(manifest_params, dict):
return errors
try:
py_sig = inspect.signature(cls.__init__)
except (ValueError, TypeError) as exc:
if warnings is not None:
warnings.append(
f"[ctor] {op_name}: inspect.signature(__init__) raised "
f"{exc.__class__.__name__}: {exc}"
)
return errors
code_params: dict[str, inspect.Parameter] = {}
for pname, p in py_sig.parameters.items():
if pname in _CTOR_INFRA_PARAMS:
continue
if p.kind in (
inspect.Parameter.VAR_POSITIONAL,
inspect.Parameter.VAR_KEYWORD,
):
continue
code_params[pname] = p
# Retired-name check: code-only occurrences of a retired ctor param
# fail outright (see _CTOR_RETIRED_PARAMS).
for pname in sorted(_CTOR_RETIRED_PARAMS & set(code_params)):
if pname not in manifest_params:
errors.append(
f"[ctor] {op_name}: param {pname!r} is retired — its "
f"dispatch mechanism lives in the kernel config dict; "
f"remove it from __init__"
)
for pname, pattrs in manifest_params.items():
if pname not in code_params:
# L1 already reports missing params; do not double-fire.
continue
if not isinstance(pattrs, dict):
continue
code_p = code_params[pname]
# Default-value parity: when the manifest declares a default the
# ctor default must match it value-for-value. Manifest sentinel
# ``REQUIRED`` (or absent ``default``) means the param has no
# manifest default. A narrow ``compat_default`` escape hatch lets
# legacy ctor signatures keep a Python default without advertising
# that value to manifest-driven callers.
manifest_default = pattrs.get("default", _MISSING)
manifest_has_default = (
manifest_default is not _MISSING and manifest_default != "REQUIRED"
)
compat_default = pattrs.get("compat_default", _MISSING)
manifest_has_compat_default = (
compat_default is not _MISSING and not manifest_has_default
)
code_has_default = code_p.default is not inspect.Parameter.empty
if manifest_has_default and not code_has_default:
err(
f"param {pname!r} has manifest default "
f"{manifest_default!r} but no default on __init__"
)
elif (not manifest_has_default) and code_has_default:
if (
not manifest_has_compat_default
or code_p.default != compat_default
):
err(
f"param {pname!r} has __init__ default "
f"{code_p.default!r} but no manifest default"
)
elif (
manifest_has_default
and code_has_default
and code_p.default != manifest_default
):
err(
f"param {pname!r} default mismatch — "
f"manifest={manifest_default!r}, code={code_p.default!r}"
)
# Keyword-only parity: manifest may declare ``kw_only: true``.
manifest_kw_only = bool(pattrs.get("kw_only", False))
code_kw_only = code_p.kind is inspect.Parameter.KEYWORD_ONLY
if manifest_kw_only != code_kw_only:
err(
f"param {pname!r} kw_only mismatch — "
f"manifest={manifest_kw_only}, code={code_kw_only}"
)
# Code-only extras detection deferred: a faithful "kwarg not declared
# anywhere in the manifest" rule needs the protocol-derived allowed
# set (params + static_dims + per-family shape/dtype variables),
# which is out of scope for the C3 helper. Strict-parity follow-up.
return errors
def check_c4_forward_signature_parity(
op_name: str,
entry: dict,
cls: type | None,
*,
warnings: list[str] | None = None,
) -> list[str]:
"""C4: forward positional names match ``signature.inputs`` order.
L1 already enforces this for the legacy signature path; the C4
function exists so the strict gate can run independently of the
older ``check_l1`` and report under the ``[forward]`` tag for
follow-up triage.
"""
errors: list[str] = []
if cls is None:
return errors
sig = entry.get("signature", {})
manifest_inputs = sig.get("inputs") or {}
if not isinstance(manifest_inputs, dict):
return errors
expected = list(manifest_inputs.keys())
positional = _forward_positional_params(cls)
if positional is None:
if warnings is not None:
detail = getattr(
_forward_positional_params, "_last_error", None
)
if detail:
warnings.append(
f"[forward] {op_name}: inspect.signature(forward) "
f"raised {detail}"
)
# Clear so a later call site sees only its own failure.
_forward_positional_params._last_error = None # type: ignore[attr-defined]
else:
warnings.append(
f"[forward] {op_name}: inspect.signature(forward) failed"
)
return errors
actual_prefix = positional[: len(expected)]
if actual_prefix != expected:
errors.append(
f"[forward] {op_name}: forward() positional names "
f"{positional!r} do not start with manifest inputs "
f"{expected!r}"
)
return errors
def check_c5_dispatch_kernel_invariant(
op_name: str,
entry: dict,
cls: type | None,
*,
warnings: list[str] | None = None,
) -> list[str]:
"""C5: ``__init__`` complies with the dispatch-kernel slot contract.
Two static checks per ``docs/design/ops-design-reference.md``:
- **S12** — ``__init__`` accepts a ``kernel_map`` keyword (or
``**kwargs`` that absorbs it).
- **S13** — ``__init__`` body contains a call ``self.dispatch_kernel(...)``.
Pure inspection: ``inspect.signature`` for S12, AST walk for S13.
No runtime construction, no GPU, no JIT — both contracts are
declarative properties of the source code.
Source-unavailable ``__init__`` (built-in / dynamically generated /
decorator-wrapped without ``__wrapped__``) degrades S13 to advisory;
S12 always runs.
"""
errors: list[str] = []
if cls is None:
return errors
# S12: signature carries kernel_map (or **kwargs).
try:
sig = inspect.signature(cls.__init__)
except (ValueError, TypeError) as exc:
if warnings is not None:
warnings.append(
f"[dispatch] {op_name}: inspect.signature(__init__) raised "
f"{exc.__class__.__name__}: {exc}; advisory"
)
return errors
has_kernel_map_kw = "kernel_map" in sig.parameters or any(
p.kind is inspect.Parameter.VAR_KEYWORD
for p in sig.parameters.values()
)
if not has_kernel_map_kw:
errors.append(
f"[dispatch] {op_name}: __init__ does not accept a "
f"'kernel_map' parameter (Slot S12) — kernel-map override is "
f"unreachable"
)
return errors
# S13: body calls self.dispatch_kernel(...).
body_calls = _init_calls_dispatch_kernel(cls)
if body_calls is None:
if warnings is not None:
warnings.append(
f"[dispatch] {op_name}: __init__ source unavailable "
f"(built-in or dynamically generated); S13 advisory"
)
return errors
if not body_calls:
errors.append(
f"[dispatch] {op_name}: __init__ body does not call "
f"self.dispatch_kernel(...) (Slot S13) — kernel_map override "
f"is silently dropped"
)
return errors
def check_c6_validate_dtypes_not_stub(
op_name: str, entry: dict, cls: type | None,
) -> list[str]:
"""C6: ``_validate_dtypes`` is not the base ``Op`` stub."""
if cls is None:
return []
from tileops.ops.op_base import Op as _OpBase
if cls._validate_dtypes is _OpBase._validate_dtypes:
return [
f"[stub] {op_name}: _validate_dtypes is the Op base stub "
f"(not implemented by the concrete class)"
]
return []
def check_c7_eval_roofline_not_stub(
op_name: str, entry: dict, cls: type | None,
) -> list[str]:
"""C7: ``eval_roofline`` is not the base ``Op`` stub."""
if cls is None:
return []
from tileops.ops.op_base import Op as _OpBase
if cls.eval_roofline is _OpBase.eval_roofline:
return [
f"[stub] {op_name}: eval_roofline is the Op base stub "
f"(not implemented by the concrete class)"
]
return []
# Tag prefixes that strict-parity checks (C1-C7) emit. Routing is
# structural, not tag-based (the orchestrator extends ``strict_errors``
# with each strict check's return); tags are triage aids only.
# ``[shape]`` / ``[dtype]`` are also emitted by the non-strict L2 / L3
# checks and may legitimately appear in ``errors`` regardless of mode —
# use ``STRICT_ONLY_TAGS`` for leakage assertions.
STRICT_TAGS: tuple[str, ...] = (
"[shape]", "[dtype]", "[ctor]", "[forward]", "[dispatch]", "[stub]",
)
# Subset of ``STRICT_TAGS`` that only strict-parity checks emit.
STRICT_ONLY_TAGS: tuple[str, ...] = (
"[ctor]", "[forward]", "[dispatch]", "[stub]",
)
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
def _is_spec_only(entry: dict) -> bool:
"""Check if the entry is spec-only.
Returns True for missing or non-string status (safe default).
"""
status = entry.get("status")
if not isinstance(status, str):
# Missing or non-string status — treat as spec-only (safe default).
# Schema validation catches this; defensive here for --levels bypass.
return True
return status == "spec-only"
def _is_bench_manifest_driven(entry: dict) -> bool:
"""Bench strictness is opt-in until all legacy benchmarks are migrated."""
return bool(entry.get("source", {}).get("bench_manifest_driven", False))
ALL_LEVELS = frozenset({"schema", "signature", "shape", "dtype", "bench"})
def validate_manifest(
manifest_path: Path | None = None,
repo_root: Path | None = None,
verbose: bool = False,
levels: frozenset[str] | None = None,
check_op: str | None = None,
strict_parity: bool = False,
) -> tuple[list[str], list[str]]:
"""Run applicable validation levels on the manifest.
Returns ``(errors, warnings)``: errors are hard failures; warnings
are informational. ``manifest_path=None`` loads the merged manifest
from the ``tileops.manifest`` package (tests pass a temp file for
synthetic single-file manifests). ``levels=None`` enables all
checks. ``check_op`` forces all levels (L0-L4) on the named op and
its variants, ignoring ``status``; all other ops are skipped.
"""
if repo_root is None:
repo_root = REPO_ROOT
if levels is None:
levels = ALL_LEVELS
if manifest_path is None:
from tileops.manifest import load_manifest
ops = load_manifest()
else:
with open(manifest_path) as f:
ops = yaml.safe_load(f) or {}
if not isinstance(ops, dict):
return [
f"--manifest-path: {manifest_path} must contain a top-level "
f"mapping of op name -> entry, got {type(ops).__name__}"
], []
# Fail fast: --check-op with a name not in the manifest
if check_op is not None and check_op not in ops:
return [f"--check-op: op '{check_op}' not found in manifest"], []
# --check-op scope: the named op plus its immediate variants, so a
# variant edit is caught when validating the primary.
variant_family: set[str] | None = None
if check_op is not None:
variant_family = {check_op} | {
name for name, ent in ops.items()
if isinstance(ent, dict) and ent.get("variant_of") == check_op
}
all_errors: list[str] = []
all_warnings: list[str] = []
# Strict-parity (C1-C7) failures: collected separately so the
# orchestrator can route them to either errors (strict mode) or
# warnings (advisory mode) once all per-op checks have run.
strict_errors: list[str] = []
# Cross-entry checks (must run before per-entry checks), scoped to
# the variant family under --check-op so unrelated ops with invalid
# variant_of references don't fail the selected op.
if "schema" in levels:
all_errors.extend(
check_variant_of_consistency(ops, scope=variant_family)
)
for op_name, entry in ops.items():
# --check-op scopes validation to the variant family; skip all others.
if variant_family is not None and op_name not in variant_family:
continue
if verbose:
print(f" Checking {op_name}...")
# schema: YAML structure validation
if "schema" in levels:
schema_errors = check_l0(
op_name, entry, warnings=all_warnings, all_op_names=ops.keys(),
)
schema_errors.extend(check_source_paths(op_name, entry, repo_root))
all_errors.extend(schema_errors)
if schema_errors:
continue
spec_only = _is_spec_only(entry)
if spec_only and check_op is None:
if verbose:
print(f" {op_name}: spec-only, skipping signature/shape/dtype/bench")
continue
# Resolve Op class once per entry so parity checks can reuse it.
source = entry.get("source", {})
op_file = source.get("op", "")
resolve_result = _resolve_op_class(op_file, op_name) if op_file else None
op_cls = resolve_result.cls if resolve_result is not None else None
# signature: Op.forward() consistency
if "signature" in levels:
all_errors.extend(check_l1(op_name, entry, warnings=all_warnings))
# shape: shape_rules syntax + _infer_output_shapes parity (C1)
if "shape" in levels:
all_errors.extend(check_l2(op_name, entry))
strict_errors.extend(
check_l2_infer_parity(
op_name, entry, op_cls, warnings=all_warnings,
)
)
# dtype: dtype string conformance + _validate_dtypes parity (C2)
if "dtype" in levels:
all_errors.extend(check_l3(op_name, entry))
strict_errors.extend(
check_l3_validate_dtypes_parity(
op_name, entry, op_cls, warnings=all_warnings,
)
)
# C3-C7: strict parity gates for status: implemented ops, each
# gated by the level whose contract it enforces (``--levels
# schema`` triggers no strict-parity work). Routed to
# strict_errors so advisory mode can downgrade them.
if "signature" in levels:
strict_errors.extend(
check_c3_ctor_signature_parity(
op_name, entry, op_cls, warnings=all_warnings,
)
)
strict_errors.extend(
check_c4_forward_signature_parity(
op_name, entry, op_cls, warnings=all_warnings,
)
)
strict_errors.extend(
check_c5_dispatch_kernel_invariant(
op_name, entry, op_cls, warnings=all_warnings,
)
)
if "dtype" in levels:
strict_errors.extend(
check_c6_validate_dtypes_not_stub(op_name, entry, op_cls)
)
if "bench" in levels:
strict_errors.extend(
check_c7_eval_roofline_not_stub(op_name, entry, op_cls)
)
# bench: benchmark uses manifest workloads
if "bench" in levels:
bench_path = entry.get("source", {}).get("bench", "")
if bench_path:
bench_errors = check_l4_benchmark(op_name, bench_path, repo_root)
if _is_bench_manifest_driven(entry):
all_errors.extend(bench_errors)
else:
all_warnings.extend(bench_errors)
# Deduplicate while preserving order: ``check_l3`` and
# ``check_l3_validate_dtypes_parity`` both surface ``dtype_combos``
# data errors (each is a valid standalone entry point).
def _dedup(items: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for item in items:
if item in seen:
continue
seen.add(item)
out.append(item)
return out
# Route strict-parity (C1-C7) failures: in strict mode they are
# blocking errors; in advisory mode they downgrade to warnings so
# the gate can land before all current main violations are fixed.
if strict_parity:
all_errors.extend(strict_errors)
else:
for s in strict_errors:
all_warnings.append(f"STRICT-PARITY (advisory): {s}")
return _dedup(all_errors), _dedup(all_warnings)
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def _parse_levels(argv: list[str]) -> frozenset[str] | None:
"""Parse ``--levels schema,shape,dtype`` from argv. Returns None when flag absent."""
for i, arg in enumerate(argv):
if arg == "--levels" and i + 1 < len(argv):
raw_str = argv[i + 1]
elif arg.startswith("--levels="):
raw_str = arg.split("=", 1)[1]
else:
continue
parsed = frozenset(t.strip().lower() for t in raw_str.split(","))
unknown = parsed - ALL_LEVELS
if unknown:
print(f"ERROR: unknown levels: {unknown}")
print(f" Valid levels: {', '.join(sorted(ALL_LEVELS))}")
sys.exit(2)
return parsed
return None
def _parse_check_op(argv: list[str]) -> str | None:
"""Parse ``--check-op <name>`` from argv.
Returns the op name, ``None`` when the flag is absent, or calls
``sys.exit(2)`` when the value is missing or looks like another flag.
"""
for i, arg in enumerate(argv):
if arg == "--check-op":
if i + 1 >= len(argv) or argv[i + 1].startswith("-"):
print("ERROR: --check-op requires an op name argument")
sys.exit(2)
return argv[i + 1]
if arg.startswith("--check-op="):
value = arg.split("=", 1)[1]
if not value or value.startswith("-"):
print("ERROR: --check-op requires an op name argument")
sys.exit(2)
return value
return None
def main() -> int:
import os
verbose = "--verbose" in sys.argv or "-v" in sys.argv
levels = _parse_levels(sys.argv)
check_op = _parse_check_op(sys.argv)
strict_parity = (
"--strict" in sys.argv
or os.environ.get("MANIFEST_STRICT_BLOCKING", "") == "1"
)
level_label = ",".join(sorted(levels)) if levels else "all"
check_op_label = f", check-op: {check_op}" if check_op else ""
mode_label = "STRICT" if strict_parity else "ADVISORY"
print(
f"Validating {MANIFEST_DIR.relative_to(REPO_ROOT)}/*.yaml "
f"(levels: {level_label}{check_op_label}, parity-mode: {mode_label})..."
)
if not strict_parity:
print(
"ADVISORY MODE — strict-parity (C1-C7) failures are reported "
"as warnings and do NOT block. Pass --strict (or set "
"MANIFEST_STRICT_BLOCKING=1) to make them blocking."
)
errors, warnings = validate_manifest(
verbose=verbose, levels=levels, check_op=check_op,
strict_parity=strict_parity,
)
if warnings:
print(f"\n{len(warnings)} warning(s):")
for w in warnings:
print(f" WARNING: {w}")
if errors:
print(f"\nFAILED: {len(errors)} error(s) found:\n")
for e in errors:
print(f" {e}")
return 1
print("All manifest checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())