diff --git a/compiler/bnode.nim b/compiler/bnode.nim index 36a5fc7dc6..776dcf562e 100644 --- a/compiler/bnode.nim +++ b/compiler/bnode.nim @@ -595,6 +595,12 @@ when defined(newIcBackend): ## `.bif`'s own filename pool plus the `ConfigRef`. result = lineInfoFromCursor(program, n.raw) + proc isAtom*(n: BNode): bool {.inline.} = + ## `ast.isAtom`, which is a pure `kind` test and so needs nothing from the + ## body scope. It exists here only because `ast.isAtom` is typed `PNode`; + ## the predicate itself is the same one. + result = n.kind >= nkNone and n.kind <= nkNilLit + # ---- predicates shared with the `PNode` spelling --------------------------- # # `ast.canRaise` / `ast.canRaiseConservative` only ever look at a node's diff --git a/compiler/ccgcalls.nim b/compiler/ccgcalls.nim index 28819f607c..f0ce207932 100644 --- a/compiler/ccgcalls.nim +++ b/compiler/ccgcalls.nim @@ -76,7 +76,7 @@ proc preventNrvo(p: BProc; dest, le, ri: PNode): bool = for r in sonsFrom(ri, 1): if isPartOf(dest, r, {pfStructural}) != arNo: return true -proc hasNoInit(call: PNode): bool {.inline.} = +proc hasNoInit(call: AnyNode): bool {.inline.} = result = call.firstSon.kind == nkSym and sfNoInit in call.firstSon.sym.flags proc isHarmlessStore(p: BProc; canRaise: bool; d: TLoc): bool = @@ -192,7 +192,7 @@ proc fixupCall(p: BProc, le, ri: PNode, d: var TLoc, proc genBoundsCheck(p: BProc; arr, a, b: TLoc; arrTyp: PType) -proc reifiedOpenArray(n: PNode): bool {.inline.} = +proc reifiedOpenArray(n: AnyNode): bool {.inline.} = var x = n while true: case x.kind @@ -447,7 +447,10 @@ proc potentialAlias(n: PNode, potentialWrites: seq[PNode]): bool = if p.aliases(n) != no or n.aliases(p) != no: return true -proc skipTrivialIndirections(n: PNode): PNode = +proc skipTrivialIndirections[T: AnyNode](n: T): T = + ## Explicitly generic rather than `(n: AnyNode): AnyNode`: two occurrences of + ## a type class in one signature are two INDEPENDENT parameters, so that + ## spelling would let the result type drift from the argument's. result = n while true: case result.kind diff --git a/compiler/ccgexprs.nim b/compiler/ccgexprs.nim index aa7b7d8c0c..b4a3f2542f 100644 --- a/compiler/ccgexprs.nim +++ b/compiler/ccgexprs.nim @@ -1357,13 +1357,13 @@ proc genBracketExpr(p: BProc; n: PNode; d: var TLoc) = else: internalError(p.config, n.info, "expr(nkBracketExpr, " & $ty.kind & ')') discard getTypeDesc(p.module, n.typ) -proc isSimpleExpr(n: PNode): bool = +proc isSimpleExpr(n: AnyNode): bool = # calls all the way down --> can stay expression based case n.kind of nkCallKinds, nkDotExpr, nkPar, nkTupleConstr, nkObjConstr, nkBracket, nkCurly, nkHiddenDeref, nkDerefExpr, nkHiddenAddr, nkHiddenStdConv, nkHiddenSubConv, nkConv, nkAddr: - for c in n: + for c in sons(n): if not isSimpleExpr(c): return false result = true of nkStmtListExpr: @@ -2377,7 +2377,7 @@ proc rdSetElemLoc(conf: ConfigRef; a: TLoc, typ: PType; result: var Snippet) = if firstOrd(conf, setType) != 0: result = cOp(Sub, NimUint, result, cIntValue(firstOrd(conf, setType))) -proc fewCmps(conf: ConfigRef; s: PNode): bool = +proc fewCmps(conf: ConfigRef; s: AnyNode): bool = # this function estimates whether it is better to emit code # for constructing the set or generating a bunch of comparisons directly if s.kind != nkCurly: return false @@ -3257,7 +3257,7 @@ proc genTupleConstr(p: BProc, n: PNode, d: var TLoc) = else: genAssignment(p, d, tmp, {}) -proc isConstClosure(n: PNode): bool {.inline.} = +proc isConstClosure(n: AnyNode): bool {.inline.} = result = n.firstSon.kind == nkSym and isRoutine(n.firstSon.sym) and n.secondSon.kind == nkNilLit diff --git a/compiler/ccgstmts.nim b/compiler/ccgstmts.nim index 15d9c6e85b..6c13a67584 100644 --- a/compiler/ccgstmts.nim +++ b/compiler/ccgstmts.nim @@ -31,10 +31,10 @@ proc registerTraverseProc(p: BProc, v: PSym) = p.module.preInitProc.procSec(cpsInit).addCallStmt(fnName, traverseProc) p.module.preInitProc.procSec(cpsInit).add("\n") -proc isAssignedImmediately(conf: ConfigRef; n: PNode): bool {.inline.} = +proc isAssignedImmediately(conf: ConfigRef; n: AnyNode): bool {.inline.} = if n.kind == nkEmpty: result = false - elif n.kind in nkCallKinds and n.firstSon != nil and n.firstSon.typ != nil and n.firstSon.typ.skipTypes(abstractInst).kind == tyProc: + elif n.kind in nkCallKinds and not n.firstSon.isNilNode and n.firstSon.typ != nil and n.firstSon.typ.skipTypes(abstractInst).kind == tyProc: if n.firstSon.kind == nkSym and sfConstructor in n.firstSon.sym.flags: result = true elif isInvalidReturnType(conf, n.firstSon.typ, true): @@ -1039,9 +1039,9 @@ proc genStringCase(p: BProc, t: PNode, stringKind: TTypeKind, d: var TLoc) = cCall(eqFn, ra, rb)): p.s(cpsStmts).addGoto(rlabel) -proc branchHasTooBigRange(b: PNode): bool = +proc branchHasTooBigRange(b: AnyNode): bool = result = false - for it in b: + for it in sons(b): # last son is block if (it.kind == nkRange) and it.secondSon.intVal - it.firstSon.intVal > RangeExpandLimit: @@ -1354,7 +1354,7 @@ proc genTryCpp(p: BProc, t: PNode, d: var TLoc) = linefmt(p, cpsStmts, "if (T$1_) std::rethrow_exception(T$1_);$n", [etmp]) endSimpleBlock(p, scope) -proc bodyCanRaise(p: BProc; n: PNode): bool = +proc bodyCanRaise(p: BProc; n: AnyNode): bool = case n.kind of nkCallKinds: result = canRaiseDisp(p, n.firstSon) @@ -1368,9 +1368,9 @@ proc bodyCanRaise(p: BProc; n: PNode): bool = nkMacroDef, nkTemplateDef, nkLambda, nkDo, nkFuncDef: result = false else: - for i in 0 ..< safeLen(n): - if bodyCanRaise(p, n[i]): return true result = false + for it in sons(n): + if bodyCanRaise(p, it): return true proc genTryGoto(p: BProc; t: PNode; d: var TLoc) = let fin = if t.lastSon.kind == nkFinally: t.lastSon else: nil diff --git a/compiler/cgen.nim b/compiler/cgen.nim index 5f2c5a6ffc..0c2e738d3a 100644 --- a/compiler/cgen.nim +++ b/compiler/cgen.nim @@ -1532,8 +1532,130 @@ when defined(newIcBackend): # process on exit; a run in which `navHits` is 0 means every lookup fell # through to the decoder and the chain is doing nothing. var navHits, navFallbacks, navRegistered: int + # Same reasoning for the predicate grinder: "0 disagreements" is only worth + # something next to how many nodes were actually graded and how many were + # excused, so all three are counted and reported together. + var gradeGraded, gradeSkipDecl, gradeSkipTyp: int - proc grindLockstep(m: BModule; prc: PSym; c: BNode; a: PNode; path: string) = + const nkIntLits = {nkCharLit..nkUInt64Lit} + + const notGradeable = {nkTypeSection, nkConstSection, nkProcDef, nkConverterDef, + nkMethodDef, nkIteratorDef, nkMacroDef, nkTemplateDef, + nkLambda, nkDo, nkFuncDef} + ## Subtrees the predicates are not graded inside, because production never + ## evaluates an expression there either — `bodyCanRaise` declares the same + ## boundary and returns `false` for the whole set without looking in. The + ## nodes inside carry unresolved types (a template's parameters, a generic's + ## `tyGenericParam`), and asking `getSize` about one is not a disagreement + ## between the two spellings, it is a question with no answer in either. + + proc ordinalRanges(a: PNode): bool = + ## Whether every `nkRange` directly under `a` has integer endpoints. The + ## gate for `branchHasTooBigRange`, which reads `intVal` off them: a `case` + ## over strings or floats has `nkOfBranch`es whose ranges hold no integer, + ## and production only ever reaches that proc from the ordinal path. Computed + ## from the AST side ALONE so the two spellings are gated identically — a + ## gate that consulted the cursor could hide the very disagreement it is + ## supposed to expose. + result = true + for it in sons(a): + if it.kind == nkRange and + (it.firstSon.kind notin nkIntLits or it.secondSon.kind notin nkIntLits): + return false + + proc grindPredicates(m: BModule; p: BProc; prc: PSym; c: BNode; a: PNode; + path: string) = + ## Every migrated pure predicate, run on BOTH spellings of the SAME node. + ## + ## The point of doing it HERE rather than once per body is coverage. A proc + ## graded at the root of a body is graded on the shapes that body happens to + ## start with; graded at every node it meets every shape the closure + ## contains, which over a standard-library build is tens of thousands of + ## nodes and effectively all of them. These predicates are pure and cheap, + ## so the whole set can be run at every node for the price of the walk that + ## is already happening. + ## + ## Only calls that are TOTAL on the node are made, and the predicates split + ## in two on that question. + ## + ## The structural ones — `isSimpleExpr`, `bodyCanRaise`, the indirection + ## walkers — read `kind`, children and (defensively) `sym`, and answer for + ## any node in a body. They are graded everywhere. + ## + ## The type-consuming ones — `isAssignedImmediately`, `fewCmps` — hand + ## `n.typ` to `getSize` / `mapType`, which are total only over types the C + ## backend can lay out. Production reaches them from exactly one shape each + ## (the value of a var definition; the set operand of an `in`), and away + ## from that shape they meet types codegen never maps — a `tyGenericParam`, + ## a `tyAnything` — and abort. That is not a disagreement between the two + ## spellings, it is a question with no answer in either, so these are graded + ## FROM THE PARENT at the position production calls them from. Widening a + ## guard until the run goes green would be the wrong move; restricting the + ## call to where it is defined is not the same thing. + template bail(what: string; cur, ast: string) = + internalError(m.config, prc.info, + "BNode/PNode disagree on " & what & " at " & path & " in " & + prc.name.s & ": cursor=" & cur & " ast=" & ast) + + template checkAt(what: string; cn: BNode; an: PNode; call: untyped) = + ## `call` is written ONCE and instantiated twice — once with `n` bound to + ## the cursor, once to the AST. Writing it twice is what would let the two + ## sides drift into asking different questions. + block: + let cv = block: + let n {.inject.} = cn + call + let av = block: + let n {.inject.} = an + call + if cv != av: bail(what, $cv, $av) + + template check(what: string; call: untyped) = checkAt(what, c, a, call) + + # Total on any well-formed node. + check "isSimpleExpr", isSimpleExpr(n) + check "reifiedOpenArray", reifiedOpenArray(n) + check "bodyCanRaise", bodyCanRaise(p, n) + + # `skipTrivialIndirections` returns a NODE, and the two spellings return + # values of different types that cannot be compared directly. Kind plus + # line info pins which node was landed on: the proc only ever walks DOWN a + # spine, so two different stopping points on the same input differ in one or + # the other unless the tree has two identical nodes at one position, which + # would make the choice immaterial anyway. + block: + let cs = skipTrivialIndirections(c) + let a2 = skipTrivialIndirections(a) + if cs.kind != a2.kind: + bail("skipTrivialIndirections kind", $cs.kind, $a2.kind) + if cs.info != a2.info: + bail("skipTrivialIndirections info", + $(m.config, cs.info), $(m.config, a2.info)) + + # Shape-guarded, matching the contexts production calls them from. + if a.kind in nkCallKinds and a.safeLen > 0: + check "hasNoInit", hasNoInit(n) + if a.kind in {nkClosure, nkPar, nkTupleConstr} and a.safeLen == 2: + check "isConstClosure", isConstClosure(n) + if a.kind == nkOfBranch and ordinalRanges(a): + check "branchHasTooBigRange", branchHasTooBigRange(n) + + # Graded from the parent — see the note above on why these two cannot be + # asked at an arbitrary node. `genVarTuple` asks about the tuple's last + # child; `genSingleVar` about the value of an `nkIdentDefs` that defines a + # symbol; `genInOp` about the set operand of an `in`. + if a.kind == nkVarTuple and a.safeLen > 0: + checkAt "isAssignedImmediately", c.lastSon, a.lastSon, + isAssignedImmediately(m.config, n) + elif a.kind == nkIdentDefs and a.safeLen == 3 and a.firstSon.kind == nkSym: + checkAt "isAssignedImmediately", son(c, 2), son(a, 2), + isAssignedImmediately(m.config, n) + if a.kind in nkCallKinds and a.safeLen > 1 and a.secondSon.kind == nkCurly and + a.secondSon.typ != nil: + checkAt "fewCmps", c.secondSon, a.secondSon, fewCmps(m.config, n) + + proc grindLockstep(m: BModule; p: BProc; prc: PSym; c: BNode; a: PNode; + path: string; gradeable: bool): bool {.discardable.} = ## Walk the `.bif` cursor and the materialised `PNode` for the SAME body in ## lockstep and require every vocabulary member to answer identically at ## every node. This grades the VOCABULARY rather than any one migrated proc, @@ -1551,6 +1673,10 @@ when defined(newIcBackend): "BNode/PNode disagree on " & what & " at " & path & " in " & prc.name.s & ": cursor=" & cur & " ast=" & ast) + # The result says: nothing ANYWHERE in this subtree hit the tolerated + # `(ht . )` type difference. Only a subtree that clean is handed to + # `grindPredicates` — see the descent below for why. + result = true if a == nil: if not c.isNilNode: bail("nil-ness", "not-nil", "nil") return @@ -1618,7 +1744,7 @@ when defined(newIcBackend): a.typField == nil and a.sym != nil and at == a.sym.typ and c.hasExplicitNilType if htNilTyp: - discard + result = false elif (ct == nil) != (at == nil): bail("typ nil-ness", (if ct == nil: "nil" else: $ct.kind) & " raw=" & c.rawDesc, @@ -1648,15 +1774,33 @@ when defined(newIcBackend): # That is the part being graded here: not just that the accessors agree, but # that they still agree when the resolution context is built by the # traversal instead of handed to it. + let gradeHere = gradeable and a.kind notin notGradeable if a.safeLen > 0: withNodeScope(nsBlock): var i = 0 for child in sons(a): let cc = son(c, i) registerDefHere(cc) - grindLockstep(m, prc, cc, child, here & "[" & $i & "]") + if not grindLockstep(m, p, prc, cc, child, here & "[" & $i & "]", + gradeHere): + result = false inc i + # AFTER the descent, and only on a subtree with no tolerated type difference + # anywhere in it. The predicates RECURSE, so one excused node poisons every + # ancestor's answer too: grading `bodyCanRaise` at a call whose callee is an + # `(ht . )` sym would re-report that one known difference as a fresh + # finding at every enclosing node. Excused, not ignored — the exclusions are + # counted, so a run that grades nothing cannot pass for a run that grades + # everything. + if not gradeHere: + inc gradeSkipDecl + elif not result: + inc gradeSkipTyp + else: + inc gradeGraded + grindPredicates(m, p, prc, c, a, here) + proc grindBNode(m: BModule; p: BProc; prc: PSym) = ## Differential grinding for the migrating vocabulary, opt-in via ## `NIM_IC_BNODE_GRIND`: run every proc that has moved to `AnyNode` over @@ -1679,13 +1823,25 @@ when defined(newIcBackend): ## ## `grindLockstep` runs last and grades the vocabulary itself rather than ## these two procs; it is the check that actually covers accessors no - ## migrated proc happens to call yet. + ## migrated proc happens to call yet, and it carries `grindPredicates` — + ## every OTHER migrated proc, run at every node of the body. + ## + ## WHAT THIS CANNOT SEE. Only a body that arrived as a deferred `nfLazyBody` + ## placeholder can be graded, and `ast2nif` defers only bodies whose root is + ## an `nkStmtList`. A one-line `proc f(x: int): int = case x ...` has an + ## `nkAsgn` body, is loaded eagerly, and never reaches this proc — 652 of + ## 1434 bodies on the reference target (`tools/icgrind`). Nor is the main + ## module graded at all: its routines are built in-process. Both are stated + ## because they are invisible from the outside — a shape added to a grind + ## target can produce exactly zero coverage and no diagnostic. if bnodeGrind < 0: bnodeGrind = ord(existsEnv("NIM_IC_BNODE_GRIND")) if bnodeGrind == 1: addExitProc proc () = stderr.writeLine "BNODEGRIND navHits=" & $navHits & - " navFallbacks=" & $navFallbacks & " navRegistered=" & $navRegistered + " navFallbacks=" & $navFallbacks & " navRegistered=" & $navRegistered & + " graded=" & $gradeGraded & " skipDecl=" & $gradeSkipDecl & + " skipTyp=" & $gradeSkipTyp if bnodeGrind == 0: return let ast = prc.ast if ast == nil or ast.safeLen <= bodyPos: return @@ -1713,7 +1869,7 @@ when defined(newIcBackend): ": cursor=" & $curPaths & " ast=" & $astPaths) withBodyScope(scope): - grindLockstep(m, prc, viaCursor, body, "") + grindLockstep(m, p, prc, viaCursor, body, "", gradeable = true) let (hits, fallbacks, registered) = navStats() navHits += hits navFallbacks += fallbacks diff --git a/tools/icgrind/grindlib.nim b/tools/icgrind/grindlib.nim new file mode 100644 index 0000000000..e756c7d1c1 --- /dev/null +++ b/tools/icgrind/grindlib.nim @@ -0,0 +1,98 @@ +# Shapes the predicate grinder needs, in an IMPORTED module with STATEMENT-LIST +# bodies. +# +# Two constraints, both structural, both learned by measuring rather than +# guessing: +# +# 1. The main module's routines are built in-process and never arrive as a +# deferred body, so nothing written in `grindme.nim` is graded at all. +# +# 2. `ast2nif` defers only bodies whose root is an `nkStmtList` (see the comment +# at the placeholder site: 82.5% of bodies, with one-line `nkAsgn` bodies the +# bulk of the rest). A `proc f(x: int): int = case x ...` has an `nkAsgn` +# body and is loaded eagerly, so it is invisible to the grinder. Every proc +# here therefore opens with a statement. + +import std/strutils + +proc risky*(x: int): int = + if x < 0: raise newException(ValueError, "neg") + result = x * 2 + +proc classifyChar*(c: char): string = + ## `branchHasTooBigRange`, false side: char ranges are all under the limit. + var r = "" + case c + of 'a'..'z': r = "lower" + of 'A'..'Z': r = "upper" + of '0'..'9', '_': r = "wordish" + else: r = "other" + result = r + +proc bigRange*(x: int): int = + ## `branchHasTooBigRange`, TRUE side: 100000 > RangeExpandLimit (256). + var r = 0 + case x + of 0..100000: r = 1 + of 100001..200000: r = 2 + else: r = 3 + result = r + +proc smallRange*(x: int): int = + var r = 0 + case x + of 0..10: r = 1 + of 11..20: r = 2 + else: r = 3 + result = r + +proc inSets*(c: char): bool = + ## `fewCmps` true side: a narrow set of an int-based element type. + discard + result = c in {'a', 'e', 'i', 'o', 'u'} and c notin {'x'..'z'} + +proc bigSet*(c: char): bool = + ## `fewCmps` false side: wide enough that emitting the set wins. + discard + result = c in {'a'..'z', 'A'..'Z', '0'..'9', '_', '-', '.', '+', '/', '=', '%'} + +proc sumOpen*(xs: openArray[int]): int = + ## `reifiedOpenArray`: an openarray PARAM is the one shape answering false. + result = 0 + for x in xs: result += x + +proc viaOpen*(xs: seq[int]): int = + result = 0 + result += sumOpen(xs) + result += sumOpen([1, 2, 3]) + result += sumOpen(xs.toOpenArray(0, 0)) + +proc adder*(n: int): proc (x: int): int = + ## A real closure — `isConstClosure` false side. + discard + result = proc (x: int): int = x + n + +proc constClosure*(): proc (x: int): int = + ## `isConstClosure` TRUE side: a top-level routine as a closure value pairs + ## the sym with a nil environment. + discard + result = risky + +proc tuples*(): (int, string) = + discard + result = (risky(2), classifyChar('q')) + +proc noInitVar*(): int = + ## `hasNoInit`: a call to a `.noinit.` routine. + var t {.noinit.}: array[4, int] + t[0] = 1 + result = t[0] + +proc guardedLib*(x: int): string = + ## `bodyCanRaise` through both a raising call and its arguments. + try: + result = $risky(x) & $risky(x + 1) + except ValueError: + result = "err" + finally: + discard diff --git a/tools/icgrind/grindme.nim b/tools/icgrind/grindme.nim new file mode 100644 index 0000000000..568bee6f70 --- /dev/null +++ b/tools/icgrind/grindme.nim @@ -0,0 +1,46 @@ +import std/[strutils, tables, algorithm] +import grindlib + +type Kind = enum kA, kB, kC +type Item = object + name: string + k: Kind + vals: seq[int] + +proc classify(i: Item): string = + case i.k + of kA: + if i.vals.len > 2: result = "many" + else: result = "few" + of kB: + for v in i.vals: + if v < 0: return "neg" + result = "pos" + of kC: + result = i.name.toUpperAscii + +proc total(i: Item): int = + for v in i.vals: result += v + +iterator pairsish(t: Table[string, int]): (string, int) = + for k, v in t: yield (k, v) + +proc build(): Table[string, int] = + result = initTable[string, int]() + var items = @[Item(name: "a", k: kA, vals: @[1, 2, 3]), + Item(name: "b", k: kB, vals: @[-1]), + Item(name: "c", k: kC, vals: @[])] + items.sort(proc (x, y: Item): int = cmp(x.name, y.name)) + for it in items: + result[classify(it)] = total(it) + +when isMainModule: + var t = build() + var keys: seq[string] = @[] + for k, v in pairsish(t): keys.add k & "=" & $v + keys.sort() + echo keys.join(",") + echo guardedLib(5), " ", guardedLib(-5) + echo classifyChar('Q'), bigRange(150000), smallRange(5), inSets('e'), bigSet('q') + echo viaOpen(@[1, 2, 3]), adder(4)(5), constClosure()(3), noInitVar() + echo tuples() diff --git a/tools/icgrind/readme.md b/tools/icgrind/readme.md new file mode 100644 index 0000000000..1eb2f9c61c --- /dev/null +++ b/tools/icgrind/readme.md @@ -0,0 +1,49 @@ +# `NIM_IC_BNODE_GRIND` target + +Input for the differential oracle in `compiler/cgen.nim` (`grindBNode`), which +runs every codegen proc that has moved to `AnyNode` over BOTH the `.bif` cursor +and the materialised `PNode` for the same body and requires the same answer. + + nim c -d:newIcBackend -o:bin/nim_grind compiler/nim.nim + NIM_IC_BNODE_GRIND=1 bin/nim_grind c --ic:on --nimcache:/tmp/ncgrind \ + tools/icgrind/grindme.nim + +A disagreement is an `internalError` naming the proc, the path within the body +and both answers. Each backend process reports its coverage on exit: + + BNODEGRIND navHits=… navFallbacks=… navRegistered=… graded=… skipDecl=… skipTyp=… + +`graded` is what the number "0 disagreements" is worth. The two skip counts are +printed beside it on purpose, so a run that grades nothing cannot be mistaken +for a run that grades everything. + +## What this target is for + +The oracle grades whatever the dependency closure contains, so most of its +coverage comes from the standard library for free. This target exists for the +shapes the stdlib closure does NOT produce often enough to exercise both +answers of a predicate — a `case` branch wider than `RangeExpandLimit`, a set +literal narrow enough for `fewCmps` to prefer comparisons, an `openArray` +parameter (the one shape `reifiedOpenArray` answers `false` for). + +## Two things that silently produce no coverage + +Both were found by counting, after adding shapes here that turned out never to +be graded at all: + +1. **The main module's routines are never graded.** They are built in-process + and never arrive as a deferred body. Anything worth grading has to live in + `grindlib.nim`, not in `grindme.nim`. + +2. **Only `nkStmtList` bodies are deferred**, so only those can be graded — see + the placeholder site in `ast2nif.loadRoutine`. A one-line + `proc f(x: int): int = case x ...` has an `nkAsgn` body, is loaded eagerly, + and is invisible to the oracle. Every routine here opens with a statement for + that reason. Measured on this target: 782 of 1434 bodies reach the grinder. + +## Known coverage gap + +`isConstClosure` is graded but only ever on its `false` side: a const closure +(`nkClosure(, nil)`) does not appear in any graded body of this +closure — the whole run contains exactly one `nkClosure` node, the real closure +in `adder`. Adding a shape here that produces one would be worth doing.