preparing for a big refactor

This commit is contained in:
ed
2026-07-11 14:48:57 -04:00
parent df723c691d
commit 987f4dee1e
+285 -256
View File
@@ -129,8 +129,20 @@ local OUTPUT_EXTENSION = ".static_analysis.txt"
--- Used by the checks to convert per-token offsets in the body to lina numbers relative to the start of `body`. --- Used by the checks to convert per-token offsets in the body to lina numbers relative to the start of `body`.
--- The atom's source-line of the body-start is added by the caller. --- The atom's source-line of the body-start is added by the caller.
--- ---
-- Memoization cache for build_body_line_index / tokenize_body.
-- Atom bodies are immutable for the duration of a validate() pass (they come from src.scan which is set once by scan-source),
-- so the same body string is safe to use as a cache key. Each call site (5 check_* functions + analyze_atom_paths)
-- was re-running the full O(body_len) scan on the SAME body. After memoization, the first call pays the O(body_len) cost,
-- every subsequent call on the same body returns the cached table in O(1).
--
-- (Future-proofing: if a caller ever mutates the returned tokens / line-index tables, the cache aliasing means those
-- mutations would leak across atoms. Current callers all read-only; safe today.)
local _body_line_index_cache = {}
local _tokenize_body_cache = {}
--- Simple line-counting: count `\n` chars from offset 1 up to the offset; that count + 1 is the line number (1-based). --- Simple line-counting: count `\n` chars from offset 1 up to the offset; that count + 1 is the line number (1-based).
local function build_body_line_index(body) local function build_body_line_index(body)
if _body_line_index_cache[body] ~= nil then return _body_line_index_cache[body] end
local index = {} local index = {}
local len = #body local len = #body
local newline_count = 0 local newline_count = 0
@@ -144,6 +156,7 @@ local function build_body_line_index(body)
end end
-- Offsets beyond the body still resolve to the final line -- Offsets beyond the body still resolve to the final line
index[len + 1] = newline_count + 1 index[len + 1] = newline_count + 1
_body_line_index_cache[body] = index
return index return index
end end
@@ -171,6 +184,7 @@ end
--- `body_rel_offset` is the char offset within `body` of the start of the token. --- `body_rel_offset` is the char offset within `body` of the start of the token.
--- Callers add it to the atom's `body_off` to get an absolute source position for line tracking. --- Callers add it to the atom's `body_off` to get an absolute source position for line tracking.
local function tokenize_body(body) local function tokenize_body(body)
if _tokenize_body_cache[body] ~= nil then return _tokenize_body_cache[body] end
local out = {} local out = {}
local len = #body local len = #body
local rel = 1 local rel = 1
@@ -213,6 +227,7 @@ local function tokenize_body(body)
end end
rel = scan rel = scan
end end
_tokenize_body_cache[body] = out
return out return out
end end
@@ -277,16 +292,17 @@ end
--- If count < the minimum declared in `duffle.GTE_PIPELINE_LATENCY[X]`, record a finding. --- If count < the minimum declared in `duffle.GTE_PIPELINE_LATENCY[X]`, record a finding.
--- Aliases are resolved against the lookup table directly; if a macro name is not in the table, emit a soft warning --- Aliases are resolved against the lookup table directly; if a macro name is not in the table, emit a soft warning
--- (the user might have added a new gte_cmdw_* but not updated duffle.lua). --- (the user might have added a new gte_cmdw_* but not updated duffle.lua).
local function check_gte_pipeline_fill(atoms, findings, line_of) --- Per-atom: walk this atom's tokens, check every `gte_cmdw_*` for pipeline-fill compliance.
for _, a in ipairs(atoms) do --- Signature changed from `(atoms, findings)` to `(atom, findings)` in Stage 1B of the plex move:
local tokens = tokenize_body(a.body) --- the per-atom iteration now lives in validate()'s single loop; each check is a per-atom predicate.
local line_in_body = build_body_line_index(a.body) local function check_gte_pipeline_fill(atom, findings)
local tn = #tokens local tokens = tokenize_body(atom.body)
local ti = 1 local line_in_body = build_body_line_index(atom.body)
while ti <= tn do local tn = #tokens
check_one_gte_cmdw(a, tokens[ti].tok, tokens, ti, line_in_body, findings) local ti = 1
ti = ti + 1 while ti <= tn do
end check_one_gte_cmdw(atom, tokens[ti].tok, tokens, ti, line_in_body, findings)
ti = ti + 1
end end
end end
@@ -299,7 +315,7 @@ end
--- ---
--- Empty bodies are not currently flagged — runtime infrastructure atoms like `MipsAtom_(yield) { mac_yield() }` --- Empty bodies are not currently flagged — runtime infrastructure atoms like `MipsAtom_(yield) { mac_yield() }`
--- and `MipsAtom_(tape_exit) { jump_reg(rret_addr), nop }` are valid as-is; mac_yield at the end is the contract. --- and `MipsAtom_(tape_exit) { jump_reg(rret_addr), nop }` are valid as-is; mac_yield at the end is the contract.
local function check_mac_yield_uniformity(atoms, findings) local function check_mac_yield_uniformity(atom, findings)
-- Per-kind semantics: -- Per-kind semantics:
-- MipsAtom_ (baked atom): exactly 1 mac_yield at the end of the body. Control transfer is the atom's job. -- MipsAtom_ (baked atom): exactly 1 mac_yield at the end of the body. Control transfer is the atom's job.
-- MipsAtomComp_ (bare static-array component): ZERO mac_yield. -- MipsAtomComp_ (bare static-array component): ZERO mac_yield.
@@ -308,88 +324,86 @@ local function check_mac_yield_uniformity(atoms, findings)
-- Same reasoning -- it's a function returning a MipsAtom slice, invoked from a parent atom. -- Same reasoning -- it's a function returning a MipsAtom slice, invoked from a parent atom.
-- --
-- The GTE pipeline-fill check applies to all 3 kinds (see check_gte_pipeline_fill). Only the mac_yield rule branches on kind. -- The GTE pipeline-fill check applies to all 3 kinds (see check_gte_pipeline_fill). Only the mac_yield rule branches on kind.
for _, a in ipairs(atoms) do local tokens = tokenize_body(atom.body)
local tokens = tokenize_body(a.body) local line_in_body = build_body_line_index(atom.body)
local line_in_body = build_body_line_index(a.body)
local count = 0 local count = 0
local last_idx = 0 local last_idx = 0
for tok_idx, t in ipairs(tokens) do for tok_idx, t in ipairs(tokens) do
local tok = t.tok local tok = t.tok
-- Match `mac_yield(...)` or just `mac_yield`. The bareword -- Match `mac_yield(...)` or just `mac_yield`. The bareword
-- variant is rare in modern style but tolerated. -- variant is rare in modern style but tolerated.
if tok:match("^mac_yield%s*%(") or tok == "mac_yield" then if tok:match("^mac_yield%s*%(") or tok == "mac_yield" then
count = count + 1 count = count + 1
last_idx = tok_idx last_idx = tok_idx
end
end
local function line_for(idx)
return atom.line + line_in_body[tokens[idx].rel]
end
if atom.kind == "atom" then
-- Baked atom: exactly 1 yield at the end.
if count == 0 then
findings[#findings + 1] = {
atom = atom.name,
line = atom.line,
check = "mac_yield_uniformity",
kind = "warning",
msg = string.format(
"%s at line %d has no `mac_yield()`; every atom must hand control to the next via mac_yield at end",
atom.name, atom.line),
}
elseif count > 1 then
findings[#findings + 1] = {
atom = atom.name,
line = line_for(last_idx),
check = "mac_yield_uniformity",
kind = "warning",
msg = string.format(
"%s at line %d has %d `mac_yield()` calls; exactly 1 is allowed",
atom.name, line_for(last_idx), count),
}
elseif last_idx < #tokens then
-- 1 call, but not the last token. We DON'T fail if the post-token is just `nop` or `nop2` or a branch with `, nop` delay slot.
-- It's the standard "yield, then BD nop" idiom.
local post_non_nop = false
for search_idx = last_idx + 1, #tokens do
local t = tokens[search_idx].tok
if t ~= "" and t ~= "nop" and t ~= "nop2"
and not t:match("%,%s*nop%)%s*$") then
post_non_nop = true
break
end
end end
end if post_non_nop then
local function line_for(idx)
return a.line + line_in_body[tokens[idx].rel]
end
if a.kind == "atom" then
-- Baked atom: exactly 1 yield at the end.
if count == 0 then
findings[#findings + 1] = { findings[#findings + 1] = {
atom = a.name, atom = atom.name,
line = a.line,
check = "mac_yield_uniformity",
kind = "warning",
msg = string.format(
"%s at line %d has no `mac_yield()`; every atom must hand control to the next via mac_yield at end",
a.name, a.line),
}
elseif count > 1 then
findings[#findings + 1] = {
atom = a.name,
line = line_for(last_idx), line = line_for(last_idx),
check = "mac_yield_uniformity", check = "mac_yield_uniformity",
kind = "warning", kind = "warning",
msg = string.format( msg = string.format(
"%s at line %d has %d `mac_yield()` calls; exactly 1 is allowed", "%s at line %d has `mac_yield()` at token %d/%d; the yield must be the LAST non-nop token in the body",
a.name, line_for(last_idx), count), atom.name, line_for(last_idx), last_idx, #tokens),
}
elseif last_idx < #tokens then
-- 1 call, but not the last token. We DON'T fail if the post-token is just `nop` or `nop2` or a branch with `, nop` delay slot.
-- It's the standard "yield, then BD nop" idiom.
local post_non_nop = false
for search_idx = last_idx + 1, #tokens do
local t = tokens[search_idx].tok
if t ~= "" and t ~= "nop" and t ~= "nop2"
and not t:match("%,%s*nop%)%s*$") then
post_non_nop = true
break
end
end
if post_non_nop then
findings[#findings + 1] = {
atom = a.name,
line = line_for(last_idx),
check = "mac_yield_uniformity",
kind = "warning",
msg = string.format(
"%s at line %d has `mac_yield()` at token %d/%d; the yield must be the LAST non-nop token in the body",
a.name, line_for(last_idx), last_idx, #tokens),
}
end
end
else
-- Component (comp_bare or comp_proc): ZERO yields.
-- The parent atom does the yield.
-- A yield inside a component would either be dead code (bare) or prematurely terminate the function (proc).
-- Both are bugs.
if count > 0 then
findings[#findings + 1] = {
atom = a.name,
line = line_for(last_idx),
check = "mac_yield_uniformity",
kind = "warning",
msg = string.format(
"%s at line %d is a %s component but has %d `mac_yield()` call(s); components must not yield (the parent atom does)",
a.name, line_for(last_idx), a.kind, count),
} }
end end
end end
else
-- Component (comp_bare or comp_proc): ZERO yields.
-- The parent atom does the yield.
-- A yield inside a component would either be dead code (bare) or prematurely terminate the function (proc).
-- Both are bugs.
if count > 0 then
findings[#findings + 1] = {
atom = atom.name,
line = line_for(last_idx),
check = "mac_yield_uniformity",
kind = "warning",
msg = string.format(
"%s at line %d is a %s component but has %d `mac_yield()` call(s); components must not yield (the parent atom does)",
atom.name, line_for(last_idx), atom.kind, count),
}
end
end end
end end
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -407,77 +421,73 @@ end
--- 1. Body MUST contain one `load_word(R_*, R_TapePtr, O_(Binds_X, field))` per field of Binds_X. Missing field = error. --- 1. Body MUST contain one `load_word(R_*, R_TapePtr, O_(Binds_X, field))` per field of Binds_X. Missing field = error.
--- 2. Body MUST contain an `add_ui_self(R_TapePtr, S_(Binds_X))` (or equivalent advance by the struct's byte count). Missing = error. --- 2. Body MUST contain an `add_ui_self(R_TapePtr, S_(Binds_X))` (or equivalent advance by the struct's byte count). Missing = error.
--- 3. atom_bind(Binds_X) where Binds_X doesn't exist = error. --- 3. atom_bind(Binds_X) where Binds_X doesn't exist = error.
local function check_abi_handoff(atoms, atom_infos, binds_index, findings) --- Per-atom: verify the atom body reads every field of its `Binds_X` from R_TapePtr and advances R_TapePtr by S_(Binds_X).
local info_by_atom = {} --- Signature changed in Stage 1B: takes `(atom, pipe_ctx, findings)` where `pipe_ctx` carries the cross-atom
for _, info in ipairs(atom_infos) do --- `info_by_atom` + `binds_index` tables (built once by validate() before the per-atom loop).
info_by_atom[info.atom_name] = info --- Per-atom iteration now lives in validate(); this is a per-atom predicate.
local function check_abi_handoff(atom, pipe_ctx, findings)
local info = pipe_ctx.info_by_atom[atom.name]
if not info or not info.binds then return end
local binds_name = info.binds
local binds = pipe_ctx.binds_index[binds_name]
if not binds then
findings[#findings + 1] = {
atom = atom.name, line = atom.line,
check = "abi_handoff", kind = "error",
msg = string.format("%s at line %d has `atom_bind(%s)` but no `typedef Struct_(%s)` declaration found in source",
atom.name, atom.line, binds_name, binds_name),
}
return
end end
local tokens = tokenize_body(atom.body)
for _, a in ipairs(atoms) do local line_in_body = build_body_line_index(atom.body)
local info = info_by_atom[a.name] local found_field_set = {}
if info and info.binds then local found_advance = false
local binds_name = info.binds local bind_re = "O_%(" .. binds_name .. ",%s*([%w_]+)%s*%)"
local binds = binds_index[binds_name] for _, t in ipairs(tokens) do
if not binds then local tok = t.tok
findings[#findings + 1] = { if tok:match("^load_word%s*%(") then
atom = a.name, line = a.line, if tok:find("R_TapePtr", 1, true) and tok:find("O_(" .. binds_name .. ",", 1, true) then
check = "abi_handoff", kind = "error", local field = tok:match(bind_re)
msg = string.format("%s at line %d has `atom_bind(%s)` but no `typedef Struct_(%s)` declaration found in source", -- scan: load_word(R_*, R_TapePtr, O_(<Binds_X>, <field>))
a.name, a.line, binds_name, binds_name), if field then
} found_field_set[field] = true
else else
local tokens = tokenize_body(a.body) local body_line = atom.line + line_in_body[t.rel]
local line_in_body = build_body_line_index(a.body)
local found_field_set = {}
local found_advance = false
local bind_re = "O_%(" .. binds_name .. ",%s*([%w_]+)%s*%)"
for _, t in ipairs(tokens) do
local tok = t.tok
if tok:match("^load_word%s*%(") then
if tok:find("R_TapePtr", 1, true) and tok:find("O_(" .. binds_name .. ",", 1, true) then
local field = tok:match(bind_re)
-- scan: load_word(R_*, R_TapePtr, O_(<Binds_X>, <field>))
if field then
found_field_set[field] = true
else
local body_line = a.line + line_in_body[t.rel]
findings[#findings + 1] = {
atom = a.name, line = body_line,
check = "abi_handoff", kind = "error",
msg = string.format("%s at line %d has load_word(R_TapePtr, O_(%s, <non-ident>)); expected O_(%s, <field>)",
a.name, body_line, binds_name, binds_name),
}
end
end
end
if tok:find("R_TapePtr", 1, true)
and tok:find("S_(" .. binds_name .. ")", 1, true) then
-- scan: add_ui_self(R_TapePtr, S_(<Binds_X>))
found_advance = true
end
end
for _, f in ipairs(binds.fields) do
if not found_field_set[f.name] then
findings[#findings + 1] = {
atom = a.name, line = a.line,
check = "abi_handoff", kind = "error",
msg = string.format("%s at line %d binds %s but never loads field `%s` from R_TapePtr (expected O_(%s, %s))",
a.name, a.line, binds_name, f.name, binds_name, f.name),
}
end
end
if not found_advance then
findings[#findings + 1] = { findings[#findings + 1] = {
atom = a.name, line = a.line, atom = atom.name, line = body_line,
check = "abi_handoff", kind = "error", check = "abi_handoff", kind = "error",
msg = string.format("%s at line %d binds %s but never advances R_TapePtr by S_(%s) (= %d bytes / %d words)", msg = string.format("%s at line %d has load_word(R_TapePtr, O_(%s, <non-ident>)); expected O_(%s, <field>)",
a.name, a.line, binds_name, binds_name, binds.bytes, binds.bytes / 4), atom.name, body_line, binds_name, binds_name),
} }
end end
end end
end end
if tok:find("R_TapePtr", 1, true)
and tok:find("S_(" .. binds_name .. ")", 1, true) then
-- scan: add_ui_self(R_TapePtr, S_(<Binds_X>))
found_advance = true
end
end
for _, f in ipairs(binds.fields) do
if not found_field_set[f.name] then
findings[#findings + 1] = {
atom = atom.name, line = atom.line,
check = "abi_handoff", kind = "error",
msg = string.format("%s at line %d binds %s but never loads field `%s` from R_TapePtr (expected O_(%s, %s))",
atom.name, atom.line, binds_name, f.name, binds_name, f.name),
}
end
end
if not found_advance then
findings[#findings + 1] = {
atom = atom.name, line = atom.line,
check = "abi_handoff", kind = "error",
msg = string.format("%s at line %d binds %s but never advances R_TapePtr by S_(%s) (= %d bytes / %d words)",
atom.name, atom.line, binds_name, binds_name, binds.bytes, binds.bytes / 4),
}
end end
end end
@@ -495,68 +505,67 @@ end
--- - Atoms containing a `mac_<name>(...)` call whose name is not in duffle.GP0_MACRO_CONTRIB emit a "new macro; update duffle.GP0_MACRO_CONTRIB" advisory. --- - Atoms containing a `mac_<name>(...)` call whose name is not in duffle.GP0_MACRO_CONTRIB emit a "new macro; update duffle.GP0_MACRO_CONTRIB" advisory.
--- ---
--- Applies only to `kind = "atom"` (baked atoms). Components don't emit full primitives. --- Applies only to `kind = "atom"` (baked atoms). Components don't emit full primitives.
local function check_gpu_portstore_shape(atoms, findings) --- Per-atom: detect which GP0 primitive the atom is emitting, sum the macro contributions,
for _, a in ipairs(atoms) do --- compare to the expected packet size. Signature changed in Stage 1B: `(atom, findings)`.
if a.kind == "atom" then local function check_gpu_portstore_shape(atom, findings)
local tokens = tokenize_body(a.body) if atom.kind ~= "atom" then return end
local line_in_body = build_body_line_index(a.body) local tokens = tokenize_body(atom.body)
local cmd_byte = nil local line_in_body = build_body_line_index(atom.body)
local cmd_line = nil local cmd_byte = nil
local contrib = 0 local cmd_line = nil
local saw_format = false local contrib = 0
local saw_prim_write = false local saw_format = false
for _, t in ipairs(tokens) do local saw_prim_write = false
local tok = t.tok for _, t in ipairs(tokens) do
-- Match `mac_format_<shape>_color(...)` and strip `_color` local tok = t.tok
-- to get the bare shape suffix (f3 / g4 / etc). -- Match `mac_format_<shape>_color(...)` and strip `_color`
local shape = tok:match("^mac_format_([%w_]+)_color%s*%(") or tok:match("^mac_format_([%w_]+)_color%s*$") -- to get the bare shape suffix (f3 / g4 / etc).
if shape and duffle.GP0_CMD_BY_SHAPE[shape] then local shape = tok:match("^mac_format_([%w_]+)_color%s*%(") or tok:match("^mac_format_([%w_]+)_color%s*$")
if not cmd_byte then if shape and duffle.GP0_CMD_BY_SHAPE[shape] then
cmd_byte = duffle.GP0_CMD_BY_SHAPE[shape]
cmd_line = a.line + line_in_body[t.rel]
end
saw_format = true
local contrib_key = "mac_format_" .. shape .. "_color"
local n = duffle.GP0_MACRO_CONTRIB[contrib_key]
if n then contrib = contrib + n end
end
local gte_store = tok:match("^mac_gte_store_[%w_]+")
if gte_store then
local n = duffle.GP0_MACRO_CONTRIB[gte_store]
if n then contrib = contrib + n end
end
local ot_tag = tok:match("^mac_insert_ot_tag_([%w_]+)")
if ot_tag then
local n = duffle.GP0_MACRO_CONTRIB["mac_insert_ot_tag_" .. ot_tag]
if n then contrib = contrib + n end
end
if tok:match("^store_word%s*%(") and tok:find("R_PrimCursor", 1, true) then
saw_prim_write = true
end
end
if not cmd_byte then if not cmd_byte then
if saw_prim_write and not saw_format then cmd_byte = duffle.GP0_CMD_BY_SHAPE[shape]
findings[#findings + 1] = { cmd_line = atom.line + line_in_body[t.rel]
atom = a.name, line = a.line,
check = "gpu_portstore_shape", kind = "warning",
msg = string.format("%s at line %d writes to R_PrimCursor via raw store_word(...)"
.. " but uses no `mac_format_*_color`; the cmd byte + word count cannot be auto-validated."
.. " Consider migrating to `mac_format_X_color` + `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X`.",
a.name, a.line),
}
end
else
local expected = duffle.GP0_CMD_SIZE[cmd_byte]
if contrib ~= expected then
findings[#findings + 1] = {
atom = a.name, line = cmd_line or a.line,
check = "gpu_portstore_shape", kind = "error",
msg = string.format("%s at line %d emits GP0 0x%02X with %d prim word(s); expected %d (cmd 0x%02X total = %d)",
a.name, cmd_line or a.line, cmd_byte, contrib, expected, cmd_byte, expected),
}
end
end end
saw_format = true
local contrib_key = "mac_format_" .. shape .. "_color"
local n = duffle.GP0_MACRO_CONTRIB[contrib_key]
if n then contrib = contrib + n end
end
local gte_store = tok:match("^mac_gte_store_[%w_]+")
if gte_store then
local n = duffle.GP0_MACRO_CONTRIB[gte_store]
if n then contrib = contrib + n end
end
local ot_tag = tok:match("^mac_insert_ot_tag_([%w_]+)")
if ot_tag then
local n = duffle.GP0_MACRO_CONTRIB["mac_insert_ot_tag_" .. ot_tag]
if n then contrib = contrib + n end
end
if tok:match("^store_word%s*%(") and tok:find("R_PrimCursor", 1, true) then
saw_prim_write = true
end
end
if not cmd_byte then
if saw_prim_write and not saw_format then
findings[#findings + 1] = {
atom = atom.name, line = atom.line,
check = "gpu_portstore_shape", kind = "warning",
msg = string.format("%s at line %d writes to R_PrimCursor via raw store_word(...)"
.. " but uses no `mac_format_*_color`; the cmd byte + word count cannot be auto-validated."
.. " Consider migrating to `mac_format_X_color` + `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X`.",
atom.name, atom.line),
}
end
else
local expected = duffle.GP0_CMD_SIZE[cmd_byte]
if contrib ~= expected then
findings[#findings + 1] = {
atom = atom.name, line = cmd_line or atom.line,
check = "gpu_portstore_shape", kind = "error",
msg = string.format("%s at line %d emits GP0 0x%02X with %d prim word(s); expected %d (cmd 0x%02X total = %d)",
atom.name, cmd_line or atom.line, cmd_byte, contrib, expected, cmd_byte, expected),
}
end end
end end
end end
@@ -758,42 +767,42 @@ local function analyze_atom_paths(atom)
local branch_count = 0 local branch_count = 0
for _ in pairs(branches) do branch_count = branch_count + 1 end for _ in pairs(branches) do branch_count = branch_count + 1 end
return { -- Mutate the pre-allocated `atom.paths` slot in place (caller owns the table).
cycles_min = cycles_min, -- Mega-struct move: a single source of truth for all per-atom path-analysis data,
cycles_max = cycles_max, -- instead of returning a fresh table that would just get copied onto 5 atom fields.
branches = branch_count, -- If a caller ever DIDN'T pre-allocate (legacy code path), fall back to a fresh slot.
paths = path_count, local p = atom.paths or {}
has_loops = has_loops, p.cycles_min = cycles_min
unknown_macros = unknown_list, p.cycles_max = cycles_max
} p.branches = branch_count
p.paths = path_count
p.has_loops = has_loops
p.unknown_macros = unknown_list
atom.paths = p
end end
--- Per-source check that emits one finding per unknown macro seen --- Per-source check that emits one finding per unknown macro seen
--- (deduplicated across atoms so the warning section doesn't get spammed with N copies of "macro X not in duffle.INSTRUCTION_LATENCY"). --- (deduplicated across atoms so the warning section doesn't get spammed with N copies of "macro X not in duffle.INSTRUCTION_LATENCY").
local function check_per_atom_cycle_budget(atoms, findings) --- Reuses `analyze_atom_paths`'s per-atom unknown_macros discovery (it's the canonical place that walks tokens
local unknown_seen = {} --- and computes per-token cycle costs). We just sort + emit.
for _, a in ipairs(atoms) do --- Per-atom: emit one finding per unknown macro seen, deduplicated across atoms (so the warning
local tokens = tokenize_body(a.body) --- section doesn't get spammed with N copies of "macro X not in duffle.INSTRUCTION_LATENCY").
local unknown_set = {} --- Reuses `analyze_atom_paths`'s per-atom unknown_macros discovery (it's the canonical place that walks tokens
for _, t in ipairs(tokens) do --- and computes per-token cycle costs). We just sort + emit.
local _, _, unknown = token_cycles(t.tok) --- Signature changed in Stage 1B: `(atom, pipe_ctx, findings)` — the `unknown_seen` dedup table lives on
if unknown then --- `pipe_ctx` so it persists across the per-atom loop in validate().
unknown_set[t.tok:match("^([%w_]+)") or "?"] = true local function check_per_atom_cycle_budget(atom, pipe_ctx, findings)
end local p = atom.paths or {}
end for _, name in ipairs(p.unknown_macros or {}) do
local unknown_macros = {} if not pipe_ctx.unknown_seen[name] then
for macro_name in pairs(unknown_set) do unknown_macros[#unknown_macros + 1] = macro_name end pipe_ctx.unknown_seen[name] = atom.line
for _, name in ipairs(unknown_macros) do findings[#findings + 1] = {
if not unknown_seen[name] then atom = atom.name, line = atom.line,
unknown_seen[name] = a.line check = "per_atom_cycle_budget", kind = "warning",
findings[#findings + 1] = { msg = string.format("%s at line %d uses macro `%s` which is not in duffle.INSTRUCTION_LATENCY; "
atom = a.name, line = a.line, .. "cycle count will be +%d per call (best-case). Add an entry to duffle.INSTRUCTION_LATENCY.",
check = "per_atom_cycle_budget", kind = "warning", atom.name, atom.line, name, duffle.UNKNOWN_INSTRUCTION_CYCLES),
msg = string.format("%s at line %d uses macro `%s` which is not in duffle.INSTRUCTION_LATENCY; " }
.. "cycle count will be +%d per call (best-case). Add an entry to duffle.INSTRUCTION_LATENCY.",
a.name, a.line, name, duffle.UNKNOWN_INSTRUCTION_CYCLES),
}
end
end end
end end
end end
@@ -816,22 +825,42 @@ local function validate(ctx, src)
binds_index[b.name] = b binds_index[b.name] = b
end end
local findings = {} -- pipe_ctx: the cross-atom shared state for the per-atom pipeline (Fleury "expose structure").
check_gte_pipeline_fill(atoms, findings) -- Pre-allocated here, mutated by each per-atom check call below. Replaces the per-check
check_mac_yield_uniformity(atoms, findings) -- local tables that used to live inside each check_* function body.
check_abi_handoff(atoms, atom_infos, binds_index, findings) -- info_by_atom — atom_name -> atom_info (built once; check_abi_handoff reads it)
check_gpu_portstore_shape(atoms, findings) -- binds_index — Binds_X -> binds struct (built once; check_abi_handoff reads it)
check_per_atom_cycle_budget(atoms, findings) -- unknown_seen — macro_name -> first atom line (accumulated across atoms; check_per_atom_cycle_budget dedups)
local info_by_atom = {}
for _, info in ipairs(atom_infos) do
info_by_atom[info.atom_name] = info
end
local pipe_ctx = {
info_by_atom = info_by_atom,
binds_index = binds_index,
unknown_seen = {},
}
-- Path-aware cycle-budget output: attach per-path cycle data to each atom. -- THE per-atom pipeline. ONE iteration of atoms; the 5 check_* functions + analyze_atom_paths
-- all run here, sharing a single tokenize_body + build_body_line_index per body.
-- Plex move: every piece of state derived from an atom body lives on `atom.paths` (the per-atom mega-struct);
-- readers (analyze_atom_paths, the 5 checks, the renderers) all consume `atom.paths`, not the raw `atoms` list.
-- Stage 1B: each check_* now takes `(atom, ...)` instead of `(atoms, findings)` — no more single-atom `{a}` shim.
local findings = {}
for _, a in ipairs(atoms) do for _, a in ipairs(atoms) do
local p = analyze_atom_paths(a) a.paths = a.paths or {}
a.paths = p a.paths.tokens = tokenize_body(a.body)
a.cycles_min = p.cycles_min a.paths.line_in_body = build_body_line_index(a.body)
a.cycles_max = p.cycles_max
a.branch_count = p.branches -- analyze_atom_paths fills the *cycles / branches / has_loops / unknown_macros* fields of a.paths.
a.unknown_macros = p.unknown_macros analyze_atom_paths(a)
a.has_loops = p.has_loops
-- Run all 5 checks on this one atom. Each is now a per-atom predicate.
check_gte_pipeline_fill(a, findings)
check_mac_yield_uniformity(a, findings)
check_abi_handoff(a, pipe_ctx, findings)
check_gpu_portstore_shape(a, findings)
check_per_atom_cycle_budget(a, pipe_ctx, findings)
end end
local errors = {} local errors = {}
@@ -987,16 +1016,16 @@ local function emit_module_static_analysis_txt(ctx, dir, dir_sources, atoms, fin
-- Sort atoms by max cycles descending for quick scanning. -- Sort atoms by max cycles descending for quick scanning.
local sorted = {} local sorted = {}
for _, a in ipairs(atoms) do sorted[#sorted + 1] = a end for _, a in ipairs(atoms) do sorted[#sorted + 1] = a end
table.sort(sorted, function(x, y) return (x.cycles_max or 0) > (y.cycles_max or 0) end) table.sort(sorted, function(x, y) return ((x.paths or {}).cycles_max or 0) > ((y.paths or {}).cycles_max or 0) end)
for _, a in ipairs(sorted) do for _, a in ipairs(sorted) do
local p = a.paths or {} local p = a.paths or {}
local br_count = p.branches or 0 local br_count = p.branches or 0
local path_count = p.paths or 0 local path_count = p.paths or 0
local loops_tag = p.has_loops and " [loop!]" or "" local loops_tag = p.has_loops and " [loop!]" or ""
local unknown_tag = "" local unknown_tag = ""
if a.unknown_macros and #a.unknown_macros > 0 then if p.unknown_macros and #p.unknown_macros > 0 then
unknown_tag = string.format(" [unknown: %s]", unknown_tag = string.format(" [unknown: %s]",
table.concat(a.unknown_macros, ", ")) table.concat(p.unknown_macros, ", "))
end end
local name_label = a.name local name_label = a.name
if multi_source and a.source_path then if multi_source and a.source_path then