TileFoundry Spec — analysis (polyhedral model + per-stage target facts)¶
This spec owns TileFoundry's fact layer: everything a later stage decides over, and nothing that decides anything itself. It has two surfaces:
| Surface | Entry | What it states |
|---|---|---|
| Polyhedral model | extract(hir) -> TileGraph |
one HIR Function body as isl domains, access relations and auto-inferred dependences — target-independent |
| Composed measurement | analyze(module, function, analysis=...) |
one root analysis and its dependency closure, leaving typed Metadata on the IR |
Per-Op semantic derivation — typeinfer, the forward access relation, shard propagation — is owned by semantic-analysis, and the registries behind it by visitor-registry; the polyhedral model consumes the forward relation (visitor-registry §4.1) rather than restating it.
Layering. The decisions taken over these facts are owned by schedule. The dependency is one-way: the schedule layer reads this layer's facts, and this layer MUST NOT import or otherwise depend on the schedule layer. The atom catalogue and the store a tile lives in are the schedule layer's own inputs and are owned there (schedule §5).
1. Polyhedral model¶
One extraction models one HIR Function body as a set of statements at
element granularity. A statement is one compute op at one call site; its
iteration domain is the op's own element domain, prefixed by one dimension per
authored loop enclosing it. Nothing here retiles, reorders, or searches: the
model states what the authored program accesses, and what must run before what.
1.1 TileUnit¶
class TileUnit:
"""One statement's identity.
Attributes:
name: attribute; isl tuple name shared by this statement's domain, reads, writes and deps pieces.
op: attribute; The HIR Call (one op at one call site) that produced this statement.
"""
name: str
op: object
- constraints:
- The structure MUST be immutable.
nameMUST be a valid isl identifier and MUST be the tuple name of everyTileGraphpiece belonging to this statement.nameMUST be unique within one extraction: a name two statements would share MUST be disambiguated with a numeric suffix, and a statement contributed by a penetrated nestedFunctionMUST additionally carry that call site's own prefix.opMUST be theCallnode itself, so a consumer can recoverop.target,op.argsandop.type; it MUST NOT be the bareOp.
1.2 TileGraph¶
class TileGraph:
"""Polyhedral model of one HIR Function body, plus its schedule.
Attributes:
domain: attribute; Union of every statement's iteration domain, one named tuple per statement.
deps: attribute; Auto-inferred read-after-write must-dependence between statement instances.
reads: attribute; Union of every statement's read access relations, statement tuple to buffer tuple.
writes: attribute; Union of every statement's write access relations.
units: attribute; One TileUnit per statement, in dependence-respecting order.
params: attribute; isl parameter name to the ShapeDim it stands for.
buffer_dtypes: attribute; Buffer tuple name to the DType its elements carry.
parallel_dims: attribute; Statement name to one flag per own domain dimension, set when that dimension carries no dependence.
tree: attribute; isl schedule tree over this model, or None before one is built.
ring: attribute; Buffer name to its decided ring depth; empty until atom selection decides it.
decisions: attribute; Recorded per-statement decisions, or None until atom selection records them.
"""
domain: "isl.union_set"
deps: "isl.union_map"
reads: "isl.union_map"
writes: "isl.union_map"
units: tuple[TileUnit, ...]
params: dict
buffer_dtypes: dict = {}
parallel_dims: dict = {}
tree: "isl.schedule | None" = None
ring: dict = {}
decisions: dict | None = None
- constraints:
- The structure MUST be immutable; a stage that adds a fact MUST return a replaced copy rather than mutate one.
domain,readsandwritesMUST be unions of per-statement pieces with one isl tuple name per statement (the domain, and the input side of the access relations) or per accessed buffer (their output side). The domain MUST NOT be a single unnamed set: the schedule tree needs one named tuple per statement.depsMUST relate statement instances and MUST be derived fromreads/writesby the extraction itself (§1.3), never supplied by a caller.unitsMUST be ordered so that every dependence runs forwards: the order is the body's SSA-DAG postorder, which makes sequencing the statements in that order legal by construction.paramsMUST resolve every isl parameter name appearing indomainback to itsShapeDim. One parameter name resolving to two differentShapeDims across statements MUST raise.buffer_dtypesMUST record an elementDTypefor every buffer an access relation names, so a byte count over an access relation needs no second walk of the HIR.parallel_dimsMUST carry exactly one flag per own domain dimension of every statement, and MUST be measured fromdomain+deps(§1.7) rather than reported by a scheduler.tree,ringanddecisionsMUST be empty (None/{}) as returned byextract, and MUST be filled only by the schedule stages that decide them (schedule §4).ringanddecisionsMUST be plain fields. Neither MAY be carried as an isl mark payload: isl marks are process-global state and MUST NOT hold a Python object.
1.3 extract¶
def extract(hir: Function) -> TileGraph: ...
class ExtractError(NotImplementedError):
"""A construct the polyhedral extraction does not model."""
extract walks hir.body in SSA-DAG postorder (dependencies before
dependents) and classifies every node it meets:
| Body node | How extract models it |
|---|---|
Call of a compute op |
one statement; or one statement per output branch when the outputs cannot share one domain (RoPE's grouped-query q / k, whose head counts differ) |
Call of TupleGetItem / Reshape / Gather |
structural view — no statement. It resolves to its source's buffer name, and the coordinate change it expresses is folded into every consumer's access map |
Call of Zeros / FullLike |
buffer declaration — no statement and no access relation: it names a fresh buffer and gives it a starting value |
Call whose target is a Function |
penetrated, not rejected: the callee's params bind to the caller's already-resolved argument expressions, its body is walked in place, and every statement and buffer it contributes is prefixed with the callee name plus a per-call-site index |
GridRegionExpr |
not a statement — it contributes one leading domain dimension to every statement it encloses (§1.4) |
Tuple |
resolved through the same substitution table; no statement |
- constraints:
- Each statement's access relations MUST come from the forward
(input-type-driven) relation service
(visitor-registry §4.1).
extractMUST stamp the statement and buffer tuple names onto each returned map and restrict it to the paired domain, and MUST reuse each access map's own formula unchanged — no retiling happens here. - Before the relation is built, every argument type MUST be narrowed to its
per-shard local shape when it carries a
ShardLayout: each meshSplit's target tensor axis divided by that mesh axis's extent, tensor rank preserved. APartial/Broadcast/Dynamicmesh axis consumes no tensor axis; a launch-provided (deferred) mesh extent narrows its axis to1. Narrowing is centralized in the extraction, so every registered relation is sharding-aware without knowing sharding exists. - A
Split-sharded axis whose extent is not a static integer, or is not evenly divisible by its mesh extent, MUST raiseExtractError. - An op with no registered forward relation MUST raise
ExtractErrornaming the op and the registration remedy.extractMUST NOT guess an access pattern and MUST NOT carry a per-op fallback. - A statement MUST write at least one value; a relation that produced no
output map MUST raise
ExtractError. - A statement with several outputs MUST write them under its own buffer name
suffixed
_<index>, matching what a downstreamTupleGetItemread resolves to. - An output access map that is not injective MUST also be recorded as a read of the same buffer: two domain points writing one output cell is only sound as a read-modify-write accumulation, and recording that self-read is what lets the dependence inference discover a reduction carry. An injective output map MUST stay a pure write.
depsMUST be inferred by isl dependence analysis overreads(sink),writes(must-source) and an initial total execution order, keeping the resulting read-after-write must-dependence. That initial order MUST place the authored loop coordinates before the per-statement postorder index, so a value written at one iteration is seen by a read at the next; a statement-first order would lose every loop carry.extractMUST raiseExtractError, naming the offender, for: a body that isNone; a body with no compute op; a self-recursive nested call; a dispatch prototype (a callee carrying variants or no body); a nested call whose arity does not match the callee's params; and aDimVarthat binds to conflicting shapes at one call site.
1.4 Authored loops¶
An authored loop is a GridRegionExpr (hir §1.2).
It is modelled as a domain dimension, not as a statement.
- constraints:
- Each enclosing loop MUST prefix one dimension to the domain of every statement it encloses, outermost loop first, so the loop axes are the leading dimensions of that domain.
- The dimension MUST range over the loop's own half-open
[start, extent)and MUST carry the raw induction value rather than a normalised trip counter; astepother than1MUST appear as a stride constraint on it. startandstepMUST be static integers.extentMAY be a bareDimVar, which becomes a same-name isl parameter bound to its own[lo, hi)range — the same treatment a dynamic tensor axis gets.- Which statements a loop encloses MUST be decided by variance, not reachability: a value is inside a loop only when it transitively reads that loop's induction variable or one of its carried args. A loop-invariant value MUST lift out, and a value read after the loop MUST be outside it even though the loop's yield produced it.
- A carried arg and the value yielded into it MUST share one buffer name. The
dependence inference then reports the loop carry as a distance-1 dependence
along that loop's dimension, read at iteration
iand written ati - 1; nothing states the carry separately. - A
Gatheron an enclosing loop's own induction variable MUST fold into the consumer's access map as the gathered axis, so iterationiaddresses slicei. A batched gather, a non-scalar index, or an index that is not an enclosing loop's induction variable MUST raiseExtractError: a data-dependent gather has no affine access map and MUST NOT be approximated.
1.5 Facts over a time relation¶
Four measurements take the time relation as data — one isl.union_map from
statement coordinates to a common time space, which the schedule layer owns and
this layer only reads.
def time_extents(tg: TileGraph, time_map: "isl.union_map") -> tuple[int, ...]: ...
def statement_time_dims(tg: TileGraph, time_map: "isl.union_map") -> dict[str, tuple[int, ...]]: ...
def carried_distances(tg: TileGraph, time_map: "isl.union_map", n_dims: int) -> dict[str, tuple[int, ...]]: ...
def access_footprints(tg: TileGraph, time_map: "isl.union_map") -> tuple[AccessFootprint, ...]: ...
- constraints:
time_extentsMUST return the per-dimension extent of the time relation's range overtg.domain. Every statement MUST share one time space, and every time dimension MUST start at0— tile counting assumes an origin-based extent — otherwise it MUST raiseExtractError.statement_time_dimsMUST report, per statement and per time dimension, the statement's own domain dimension that time dimension travels with, or-1where it is constant there. A time dimension mixing two domain dimensions (a skewed band) MUST raiseExtractError: no per-axis tile size describes it.carried_distancesMUST report, per buffer, the largest dependence distance isl reports along each of the firstn_dimstime dimensions. A dependence MUST be attributed to every buffer its source writes and its sink reads, which for a read-after-write must-dependence is exactly the memory it travels through.access_footprintsMUST return oneAccessFootprintper read and per write oftg, expressed against the time relation's range so that a size per time dimension sizes it.
1.6 AxisExtent / AccessFootprint¶
class AxisExtent:
"""One buffer dimension's reach inside one statement's whole access.
Attributes:
axes: attribute; Time dimensions that reach this dimension; empty when none does.
extent: attribute; Number of elements of this dimension the access reaches.
"""
axes: tuple[int, ...]
extent: int
class AccessFootprint:
"""One (statement, buffer) access, sized per buffer dimension.
Attributes:
statement: attribute; isl tuple name of the accessing statement.
buffer: attribute; isl tuple name of the accessed buffer.
is_read: attribute; True for a read access, False for a write.
dims: attribute; One AxisExtent per buffer dimension.
elem_bytes: attribute; Bytes one element of the buffer occupies.
"""
statement: str
buffer: str
is_read: bool
dims: tuple[AxisExtent, ...]
elem_bytes: int
- constraints:
- Both structures MUST be immutable.
extentMUST be measured off the access relation, never derived from a tile size: a dimension read at a rate reaches fewer elements than the iterations that reach it.axescarries no size, only the reuse fact. An emptyaxesMUST mean no time dimension reaches that dimension — it is re-read in full by every iteration.- The element count of one access MUST be the product over
dims. That count is the bounding box of the access's range: exact for a box-shaped access, an upper bound for one that leaves holes inside its own box. elem_bytesMUST be the buffer's element size in whole bytes, resolved fromTileGraph.buffer_dtypes. A buffer with no recorded dtype MUST raiseExtractError.
1.7 Parallel dimensions¶
TileGraph.parallel_dims is the fact isl names coincident, measured here
rather than obtained from a scheduler.
- constraints:
- Only a statement's self-dependence MAY constrain its own dimensions: the schedule layer sequences statements, so every cross-statement dependence is already satisfied by that order.
- A dimension MUST be reported parallel when every self-dependence has
distance
0there, and MUST NOT be otherwise. A statement with no self-dependence MUST have every dimension reported parallel.
2. Authored-HIR metrics¶
The measurement entry is the composed operation (§3). What a human or a tool reads is a rendering of its semantic result and of the records it left on the IR (inspection); the command line composes one call per requested analysis and renders the results together (cli §Analyze).
- constraints:
- A rendering MUST NOT be a field of the semantic result, and an analysis MUST NOT format one. A family that rendered its own text would be deciding presentation, and two families would then disagree about it.
- A rendering MUST report what the caller requested. A requested analysis pulls its dependencies in, so records reach the IR that nobody asked to see; those records MUST stay on the IR and MUST NOT be reported. Which records an analysis owns MUST be read from its registration (§3.1) rather than from a second table.
- Every rendering of one run MUST make that selection through one shared decision. A summary and an annotated program are two views of the same run, and choosing separately is how one of them comes to show a dependency the caller never asked about.
- A rendering MUST show only records that were actually written, so it never reports a measurement that did not happen.
- Every reported quantity MUST come from a record, except a total that is the exact sum of records that state it. A quantity that is not derivable that way MUST be recorded by the analysis that computed it, not reassembled by a renderer.
- Two formats over one report MUST carry the same conclusions. They MUST be built from one intermediate structure rather than formatted independently.
2.1 Metadata records¶
class TrafficBytes:
"""Read and write byte counts for one memory hierarchy level.
Attributes:
read: attribute; Bytes read at this level.
write: attribute; Bytes written at this level.
"""
read: int = 0
write: int = 0
class ComputeCostMetadata(IRMetadata):
"""One Call's logical work, as the authored program states it.
Attributes:
flops: attribute; Flop count per compute DType name, sorted by name.
traffic: attribute; TrafficBytes per storage level name.
execution_count: attribute; How many times the call runs.
operands: attribute; TrafficBytes per operand, positional against (*call.args, call); present only for a direct primitive call.
"""
flops: tuple[tuple[str, int], ...] = ()
traffic: tuple[tuple[str, TrafficBytes], ...] = ()
execution_count: int = 1
operands: tuple[TrafficBytes, ...] = ()
class LevelFootprint:
"""How much of one memory level a function needs at its peak.
Attributes:
level: attribute; The memory level name.
peak_bytes: attribute; The largest simultaneous claim on the level.
persistent_bytes: attribute; The part that cannot be reclaimed.
capacity_bytes: attribute; The stated capacity, or None when unknown.
"""
level: str
peak_bytes: int
persistent_bytes: int
capacity_bytes: int | None = None
class ValueLifetime:
"""One value's residency, as positions in the function's value order.
Attributes:
binding: attribute; The parameter or authored binding name.
level: attribute; The memory level the value occupies.
bytes: attribute; Bytes the value occupies at that level.
defined_at: attribute; Position the value becomes resident.
last_used_at: attribute; Position it may be released.
persistent: attribute; Whether it is held for the whole function.
"""
binding: str
level: str
bytes: int
defined_at: int
last_used_at: int
persistent: bool = False
class MemoryMetadata(IRMetadata):
"""One function's memory behaviour against one target's hierarchy.
Attributes:
footprint: attribute; One row per level the function places values in.
traffic: attribute; TrafficBytes per level, over the whole function.
lifetimes: attribute; One entry per value residency.
advisories: attribute; Capacity findings that do not invalidate the program.
"""
footprint: tuple[LevelFootprint, ...] = ()
traffic: tuple[tuple[str, TrafficBytes], ...] = ()
lifetimes: tuple[ValueLifetime, ...] = ()
advisories: tuple[str, ...] = ()
class RooflineMetadata(IRMetadata):
"""A lower bound on time, and which side of the machine sets it.
Attributes:
compute_ns: attribute; Time the flops imply at the target's rates.
memory_ns: attribute; Time the traffic implies at the target's bandwidth.
theoretical_ns: attribute; The bound the two imply.
bound_by: attribute; Which resource set the bound.
"""
compute_ns: int = 0
memory_ns: int = 0
theoretical_ns: int = 0
bound_by: str = "none"
class TimelineMetadata(IRMetadata):
"""A modeled placement on the nominal timeline.
Attributes:
grid_units: attribute; Parallel-unit extent this placement covers.
waves: attribute; Number of waves issued.
start_ns: attribute; Modeled start, in ns.
end_ns: attribute; Modeled end, in ns.
"""
grid_units: int = 1
waves: int = 1
start_ns: int = 0
end_ns: int = 0
- constraints:
- Every record MUST be immutable and MUST render one single-line comment form, so a printer can attach it without knowing the record type (inspection).
- A record's attachment point MUST say what it is about, and one record type
MUST mean the same quantity wherever it hangs. A
Call-attached record describes that call; aFunction-attached record describes that whole function. A type MUST NOT change meaning with its attachment point. - A
Function-attached record MUST NOT be read as data the Function inherently carries. It states what one analysis found when a call reached that function, and there MUST be no cross-call cache behind it. ComputeCostMetadataMUST be derivable from the authored program alone. Its flops MUST come from the op's registered cost evaluator (visitor-registry) scaled by the execution count, and its bytes from the logical types the operands and result carry. It MUST NOT read any Target fact, so one authored call carries the same record on every backend. An op with no registered cost evaluator MUST raiseAnalysisError.- The execution count MUST be the product of the execution-topology extents the call's value meshes carry and the owning Module declares. Conflicting extents for one topology name MUST raise rather than be reconciled.
operandsMUST be positional against(*call.args, call): one entry per argument in order, then the result. Each entry MUST be the amount the op's cost evaluator reported for that operand, unmodified. Two operands MAY name the same value; the position is what distinguishes them.- Which level an operand's bytes are charged at MUST come from that operand's
Type. Where the Type occupies one level, the per-level
trafficis those same amounts regrouped. Where it spans several, the per-leveltrafficMUST charge the whole Type at each of them, in the directions the op reported movement in: no operand-level count states how the bytes divide, so the aggregate takes a conservative bound while the operand entry keeps the reported amount. The two MUST NOT be assumed equal in general. - Operand attribution is defined for a call on a primitive op only. A call
into another
FunctionMUST report that function's totals and MUST record no operands: that traffic is an aggregate over the callee's own operands, and no breakdown of this call's arguments describes it. A rendering MUST omit the operand breakdown for such a call rather than emit it empty. MemoryMetadataMUST be attached perFunction: a peak is a property of the whole function's live ranges and belongs to no single expression.- Parameters MUST be resident from the start of the value order. A parameter
declared constant is a weight and MUST be
persistent, held past its last reader for the whole function; every other value MUST be measured by first definition and last use. A pure view — a reshape or a transpose — MUST allocate nothing. RooflineMetadataMUST be computed from the recorded work rather than from a second reading of the program. On aFunctionthe compute and memory times MUST each be summed over the function before being compared, because aggregating per-Call bounds instead would charge the machine for rounding and for overlap it does not suffer.- A dtype or a level the target publishes no rate for MUST contribute nothing to the bound, and MUST NOT be filled in with an assumed rate. Work whose rate is unpublished MUST still report a non-zero bound rather than read as free.
TimelineMetadataMUST be attached per execution unit — the group of calls fused by compatible local placement and equal parallel extent — so every call of one unit MUST carry the same record. A unit whose extent exceeds the parallel capacity MUST be modelled as consecutive waves. On aFunctionthe record MUST span the whole plan, from the origin to the solved makespan.- The timeline is a modeled plan. It MUST NOT be read as a guarantee about lowering, physical occupancy, or runtime performance.
2.2 Analysis families¶
The first families are compute-cost, memory, roofline, and timeline.
Each owns one record type and declares what it needs.
| Selector | Requires | Owns | Rests on |
|---|---|---|---|
compute-cost |
— | ComputeCostMetadata |
the authored program only |
memory |
compute-cost |
MemoryMetadata |
MemoryHierarchyFacts |
roofline |
memory, compute-cost |
RooflineMetadata |
ThroughputFacts |
timeline |
roofline |
TimelineMetadata |
ParallelCapacityFacts |
- constraints:
- A family MUST obtain hardware only through a Facts aggregate it declares (target §11). Common analysis code MUST NOT branch on a concrete Target type, MUST NOT call a complete Target analyzer, and MUST NOT resolve an undeclared Target to a default.
- A family MUST read a dependency's record rather than recompute what it states. A number with two derivations has two answers.
- Logical work and lifetime MUST remain target-independent. Physical capacity, hierarchy relationships, and throughput comparisons are target-aware.
2.3 Memory hierarchy facts¶
class MemoryRelationKind(Enum):
"""How two memory levels are related."""
CACHES = "caches"
SHARES_CAPACITY_WITH = "shares_capacity_with"
class ExplicitMemoryLevelFacts:
"""A level a program places values in by name.
Attributes:
name: attribute; The storage level name.
capacity_bytes: attribute; Stated capacity, or None when unknown.
scope: attribute; The topology level the capacity is stated per.
"""
name: str
capacity_bytes: int | None
scope: str
class ImplicitMemoryLevelFacts:
"""A level traffic passes through without being placed there.
Attributes:
name: attribute; The cache level name.
capacity_bytes: attribute; Stated capacity, or None when unknown.
scope: attribute; The topology level the capacity is stated per.
"""
name: str
capacity_bytes: int | None
scope: str
class MemoryLevelRelation:
"""One edge between two memory levels.
Attributes:
kind: attribute; Which relationship this edge states.
near: attribute; The level closer to the compute units.
far: attribute; The level on the other side of the edge.
shared_capacity_bytes: attribute; Size of the divided block, on a sharing edge.
"""
kind: MemoryRelationKind
near: str
far: str
shared_capacity_bytes: int | None = None
class MemoryHierarchyFacts:
"""Every memory level of one target, as a flat graph.
Attributes:
explicit_levels: attribute; The levels a program names.
implicit_levels: attribute; The levels traffic only passes through.
relations: attribute; The edges between them.
"""
explicit_levels: tuple[ExplicitMemoryLevelFacts, ...]
implicit_levels: tuple[ImplicitMemoryLevelFacts, ...]
relations: tuple[MemoryLevelRelation, ...]
- constraints:
- The levels MUST be two flat tuples and the structure MUST be a separate edge list. A hierarchy that is only ever a tree cannot state that a cache and an addressable level divide one physical block, which is the relationship that decides how much of that block either one gets.
- A GPU projection MUST cover the explicit levels a program can name and the caches traffic passes through, and MUST state that L1 caches L2, that L2 caches global memory, and that L1 divides one physical block with shared memory. A target with no such sharing MUST express that by having no such edge, not by a placeholder one.
- An implicit level MUST NOT be given a capacity of its own where its usable capacity depends on the program. That capacity MUST be derived from the sharing edge and the sharing level's measured peak.
- Exceeding an explicit level's stated capacity MUST raise
AnalysisError: the program placed more there than the level holds. Exceeding an implicit level's capacity MUST be recorded as an advisory and MUST NOT fail the call, because a working set larger than a cache still runs.
3. Composed analysis¶
tilefoundry.analysis.api.analyze is the dependency-composed measurement
operation. One call selects one root analysis by name; the operation resolves
what that root transitively needs, runs each member once, and reports what ran.
class AnalysisResult:
"""What one composed Analyze call computed."""
module: "Module"
function: "Function"
analysis: str
executed: tuple[str, ...]
metadata_types: tuple[type[IRMetadata], ...]
def analyze(
module: "Module",
function: "Function",
*,
analysis: str,
options: object | None = None,
dims: "Mapping[str, int] | None" = None,
) -> AnalysisResult: ...
- constraints:
- One call MUST select exactly one root analysis. A caller wanting several roots MUST call the operation once per root.
- The Function MUST be one the Module owns: one it declares, or a specialization variant of one it declares (core-ir §1). A Function derived by specialising one of these MUST be refused, so that ownership is settled before anything is rebuilt.
dimsstates one extent per dimension the Function declares as a range. An analysis counts elements and holds them against a machine, and neither has an answer for a range, so a Function stating a range MUST be analysed at a chosen size rather than as authored.dims=NoneMUST behave as a call that states no size: the Function is analysed as authored, andAnalysisResult.functionMUST be the object the caller supplied.- When
dimsis stated it MUST be non-empty; every key MUST name a dimension the Function declares as a range; every value MUST be an integer inside that dimension's declared bounds; every dimension the Function selects a variant on MUST be given a value; and no dimension MAY remain a range after substitution. Each of these MUST fail with an Analysis domain error. A stateddimsMUST NOT be silently ignored, including when the Function declares no range at all. - Variant resolution and substitution MUST happen after the ownership check and before any algorithm runs. Exactly one variant MUST cover the stated size; none and more than one MUST both fail.
- When
dimsis stated,AnalysisResult.functionMUST be the concrete Function the records were written onto, derived from the Function the caller supplied, and MUST record both that Function as the one it was specialised from and the extents it was specialised at.AnalysisResult.moduleMUST remain the Module the caller supplied. A reader given the symbolic input would find no records on it. - The recorded extents MUST be what identifies which size a derived Function is at. They MUST NOT be inferred from its signature: a dimension occurring only in a loop bound, a body operation's attribute, or a nested callee leaves the signature identical at every extent, so two sizes would be indistinguishable to anything comparing signatures.
- The operation MUST resolve the root's full transitive dependency closure,
order it so every dependency precedes its dependants, and execute each
member exactly once per call.
executedMUST report that order, so a shared dependency appears once. - Dependencies MUST be resolved under the same exact concrete Target as the
root, obtained from
Module.resolve_target(). - A dependency cycle MUST fail and MUST name the path that closes it. A missing root and a missing dependency MUST be distinguishable: one is the caller's selector, the other a broken registration.
- Type inference and validation MUST each run once per call, before any analysis. No analysis MAY run once either has rejected the IR, because an analysis reads inferred types and assumes a verified function.
- Re-running MUST recompute the closure and refresh the Metadata that closure owns. There MUST be no cross-call cache. Metadata owned by nothing in the closure MUST be left untouched.
metadata_typesMUST list the Metadata types the call actually wrote onto the IR, in execution order and without repeats. An analysis that declares a type but writes no record for this function MUST NOT contribute it, so a renderer is never sent after records that are not there.AnalysisResultMUST be semantic. Human text, JSON, and annotated HIR are renderings of it and of the Metadata on the IR, and MUST NOT be fields of it.
3.1 Analysis registration¶
class AnalysisAlgorithm:
"""One registered analysis: its identity, needs, and owned Metadata."""
selector: str
run: AnalysisCallable
requires: tuple[str, ...]
produces: tuple[type[IRMetadata], ...]
def register_analysis(
target_type: type,
selector: str,
*,
requires: tuple[str, ...] = (),
produces: tuple[type[IRMetadata], ...] = (),
) -> "Callable[[AnalysisCallable], AnalysisCallable]": ...
- constraints:
- An analysis MUST be registered under the exact
(Target concrete type, selector)pair, in the shared algorithm registry contract (code-organization). A base-class registration MUST NOT serve a subclass, and there MUST be no default-Target fallback: two targets sharing a base can need different implementations. - A target-independent analysis MUST still be registered once per supported target, so the support matrix is read from the registrations rather than inferred from an inheritance chain.
- A duplicate registration for one exact pair MUST fail rather than replace, so dispatch cannot depend on import order.
- A declaration MUST be rejected when it requires itself, repeats a
dependency, produces the same Metadata type twice, or names a
producesentry that is not anIRMetadatasubclass. - An analysis MAY change only the Metadata types its registration declares. Ownership MUST be enforced against what reached the IR rather than against what the analysis reports, and MUST cover addition, replacement, and removal alike: deleting another analysis's record changes the IR as much as overwriting it. An equal-valued overwrite of another analysis's record MUST also count as a violation.