Skip to content

Commit 7d9514a

Browse files
author
Jules
committed
Add DDM grammar productivity checker with correctness proofs
Decides whether each declared category in a dialect admits a finite parse tree. Productivity is rooted in `frameworkAtomicCategories` — the categories the parser constructs directly — with all other categories derived from operator chains. * Strata/DDM/Analysis/Productivity.lean — algorithm + wrapper * Strata/DDM/Analysis/Productivity/Spec.lean — sound, complete, and correctness proofs for the fixpoint kernel and the top-level wrapper * StrataTest/DDM/Analysis/ProductivityTest.lean — unit tests * Scripts/ProductivityCheck.lean — `lake exe productivityCheck` One sorry remains: `transitiveImports.fuel_suffices` — proving the `n³` fuel bound is enough for the dialect-import-graph traversal.
1 parent 5f5a701 commit 7d9514a

5 files changed

Lines changed: 1156 additions & 0 deletions

File tree

Scripts/ProductivityCheck.lean

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/-
2+
Copyright Strata Contributors
3+
4+
SPDX-License-Identifier: Apache-2.0 OR MIT
5+
-/
6+
import Strata.DDM.Analysis.Productivity
7+
import Strata.DDM.BuiltinDialects
8+
import Strata.Languages.Core.DDMTransform.Grammar
9+
import Strata.Languages.Boole.Boole
10+
import Strata.Languages.Dyn.DDMTransform.Parse
11+
import Strata.Languages.Python.PythonDialect
12+
import Strata.Languages.B3.DDMTransform.DefinitionAST
13+
import Strata.Languages.B3.DDMTransform.ParseCST
14+
import Strata.Languages.Laurel.Grammar.LaurelGrammar
15+
import Strata.DL.SMT.DDMTransform.Parse
16+
17+
/-! Quick CLI: `lake exe productivityCheck [<DialectName>|--all]`.
18+
The only trusted base is `frameworkAtomicCategories`; every other
19+
category must derive its productivity from operator chains rooted in
20+
those atoms. -/
21+
22+
private def knownDialects : Array Strata.Dialect :=
23+
#[ Strata.initDialect
24+
, Strata.headerDialect
25+
, Strata.StrataDDL
26+
, Strata.smtReservedKeywordsDialect
27+
, Strata.SMTCore
28+
, Strata.SMT
29+
, Strata.SMTResponse
30+
, Strata.Core
31+
, Strata.Boole
32+
, Strata.Dyn
33+
, Strata.Python.Python
34+
, Strata.B3AST
35+
, Strata.B3CST
36+
, Strata.Laurel.Laurel
37+
]
38+
39+
open Strata
40+
41+
private def runOn (name : DialectName) : IO UInt32 := do
42+
let dialects : DialectMap := DialectMap.ofList! knownDialects.toList
43+
if name ∉ dialects then
44+
IO.eprintln s!"ERR: unknown dialect '{name}'"
45+
IO.eprintln "Bundled dialects:"
46+
for d in knownDialects do
47+
IO.eprintln s!" {d.name}"
48+
return 1
49+
let r := Strata.DDM.Productivity.check dialects name
50+
IO.println r.format
51+
return (if r.isOk then 0 else 1)
52+
53+
private def runAll : IO UInt32 := do
54+
let dialects : DialectMap := DialectMap.ofList! knownDialects.toList
55+
let mut bad : Nat := 0
56+
for d in knownDialects do
57+
let r := Strata.DDM.Productivity.check dialects d.name
58+
let mark := if r.isOk then "✓" else "✗"
59+
IO.println s!"{mark} {d.name}: {r.productive.size} productive, {r.unproductive.size} unproductive"
60+
if !r.isOk then
61+
bad := bad + 1
62+
for u in r.unproductive do
63+
IO.println s!" - {u.category.fullName} ({u.blockers.size} blocker(s))"
64+
for b in u.blockers do
65+
let parts := String.intercalate ", " (b.unmetArgCats.toList.map QualifiedIdent.fullName)
66+
IO.println s!" op {b.opName} blocked on: {parts}"
67+
IO.println ""
68+
if bad == 0 then
69+
IO.println s!"All {knownDialects.size} dialects productive."
70+
return 0
71+
else
72+
IO.println s!"{bad} of {knownDialects.size} dialects have unproductive categories."
73+
return 1
74+
75+
def main (args : List String) : IO UInt32 := do
76+
match args with
77+
| [] | ["--help"] =>
78+
IO.println "Usage: lake exe productivityCheck [<DialectName>|--all]"
79+
IO.println ""
80+
IO.println "Bundled dialects:"
81+
for d in knownDialects do
82+
IO.println s!" {d.name}"
83+
return 0
84+
| ["--all"] => runAll
85+
| [name] => runOn name
86+
| _ =>
87+
IO.eprintln "ERR: expected exactly one dialect name, --all, or --help"
88+
return 1
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
/-
2+
Copyright Strata Contributors
3+
4+
SPDX-License-Identifier: Apache-2.0 OR MIT
5+
-/
6+
module
7+
8+
public import Strata.DDM.AST
9+
10+
/-!
11+
# Grammar Productivity Checker
12+
13+
A category is *productive* iff some operator builds it from productive
14+
arguments. The only base case is `frameworkAtomicCategories` — what
15+
the parser constructs directly.
16+
-/
17+
18+
public section
19+
20+
namespace Strata.DDM.Productivity
21+
22+
/-! ## Skeleton types -/
23+
24+
structure OpInfo where
25+
name : String
26+
result : QualifiedIdent
27+
args : Array QualifiedIdent
28+
deriving Repr, Inhabited
29+
30+
structure Blocker where
31+
opName : String
32+
unmetArgCats : Array QualifiedIdent
33+
deriving Repr, Inhabited
34+
35+
structure UnproductiveCategory where
36+
category : QualifiedIdent
37+
blockers : Array Blocker
38+
deriving Repr, Inhabited
39+
40+
structure Result where
41+
dialect : DialectName
42+
productive : Array QualifiedIdent
43+
unproductive : Array UnproductiveCategory
44+
deriving Repr, Inhabited
45+
46+
namespace Result
47+
48+
def isOk (r : Result) : Bool := r.unproductive.isEmpty
49+
50+
end Result
51+
52+
/-! ## Skeleton extraction -/
53+
54+
/-- Containers whose empty form is a valid value. -/
55+
def nullableContainers : Array QualifiedIdent := #[
56+
q`Init.Seq, q`Init.Option,
57+
q`Init.CommaSepBy, q`Init.SpaceSepBy, q`Init.SpacePrefixSepBy,
58+
q`Init.NewlineSepBy, q`Init.SemicolonSepBy
59+
]
60+
61+
private def isNullableContainer (c : SyntaxCat) : Bool :=
62+
nullableContainers.any (· == c.name)
63+
64+
private def isNonemptyMeta (m : Metadata) : Bool :=
65+
q`StrataDDL.nonempty ∈ m
66+
67+
/-- Every category in `c`'s type tree, DFS, head first. -/
68+
partial def collectAllCategories (c : SyntaxCat) : Array QualifiedIdent :=
69+
c.args.foldl (init := #[c.name]) fun acc a => acc ++ collectAllCategories a
70+
71+
/-- Argument's productivity obligations, accounting for `@[nonempty]`. -/
72+
def collectArgCategories (ad : ArgDecl) : Array QualifiedIdent :=
73+
let cat := ad.kind.categoryOf
74+
if cat.name == q`Init.Option then
75+
#[cat.name]
76+
else if isNullableContainer cat && !isNonemptyMeta ad.metadata then
77+
#[cat.name]
78+
else
79+
collectAllCategories cat
80+
81+
def opInfoOfDecl (decl : OpDecl) : OpInfo :=
82+
let args := decl.argDecls.toArray.foldl (init := #[]) fun acc ad =>
83+
acc ++ collectArgCategories ad
84+
{ name := decl.name, result := decl.category, args }
85+
86+
def extractOps (d : Dialect) : Array OpInfo := Id.run do
87+
let mut out : Array OpInfo := #[]
88+
for decl in d.declarations do
89+
if let .op od := decl then
90+
out := out.push (opInfoOfDecl od)
91+
return out
92+
93+
def declaredCategories (d : Dialect) : Array QualifiedIdent := Id.run do
94+
let mut out : Array QualifiedIdent := #[]
95+
for decl in d.declarations do
96+
if let .syncat sc := decl then
97+
out := out.push { dialect := d.name, name := sc.name }
98+
return out
99+
100+
/-! ## Trusted seed (framework atoms only) -/
101+
102+
/-- Categories the parser constructs directly — the only trusted base. -/
103+
def frameworkAtomicCategories : Array QualifiedIdent := #[
104+
q`Init.Ident, q`Init.Num, q`Init.Str, q`Init.ByteArray, q`Init.Decimal,
105+
q`Init.Seq, q`Init.Option,
106+
q`Init.CommaSepBy, q`Init.SpaceSepBy, q`Init.SpacePrefixSepBy,
107+
q`Init.NewlineSepBy, q`Init.SemicolonSepBy
108+
]
109+
110+
@[expose] def trustedCategories : Std.HashSet QualifiedIdent :=
111+
frameworkAtomicCategories.foldl (init := (∅ : Std.HashSet QualifiedIdent))
112+
(fun acc c => acc.insert c)
113+
114+
theorem trustedCategories_eq :
115+
trustedCategories =
116+
frameworkAtomicCategories.foldl (init := (∅ : Std.HashSet QualifiedIdent))
117+
(fun acc c => acc.insert c) := rfl
118+
119+
/-! ## Import closure -/
120+
121+
private def transitiveImports.step (dialects : DialectMap)
122+
(state : Std.HashSet DialectName × Array DialectName)
123+
: Std.HashSet DialectName × Array DialectName :=
124+
let (seen, stack) := state
125+
match stack.back? with
126+
| none => (seen, stack)
127+
| some name =>
128+
let stack := stack.pop
129+
if seen.contains name then (seen, stack)
130+
else
131+
let seen := seen.insert name
132+
let stack :=
133+
if hMem : name ∈ dialects then
134+
(dialects[name]'hMem).imports.foldl (init := stack) fun s imp =>
135+
if seen.contains imp then s else s.push imp
136+
else stack
137+
(seen, stack)
138+
139+
private def transitiveImports.loop (dialects : DialectMap)
140+
: Nat → Std.HashSet DialectName × Array DialectName
141+
→ Std.HashSet DialectName
142+
| 0, (seen, _) => seen
143+
| _ + 1, (seen, #[]) => seen
144+
| n + 1, state =>
145+
transitiveImports.loop dialects n (transitiveImports.step dialects state)
146+
147+
/-- Fuel for the closure walk: pushes ≤ `n` (fresh-seen) × `n`
148+
(imports per dialect) = `n²`; `n³` is comfortable slack. -/
149+
def transitiveImports.fuel (n : Nat) : Nat := n * n * n
150+
151+
/-- Dialects reachable from `start` via `imports` (including `start`). -/
152+
def transitiveImports
153+
(dialects : DialectMap) (start : DialectName) : Std.HashSet DialectName :=
154+
let n := dialects.toList.length
155+
transitiveImports.loop dialects (transitiveImports.fuel n) ({}, #[start])
156+
157+
/-- Spec: `d` is reachable from `start` via `imports` edges. -/
158+
inductive ReachableViaImports (D : DialectMap) (start : DialectName)
159+
: DialectName → Prop where
160+
| self : ReachableViaImports D start start
161+
| step : ∀ {a b}, ReachableViaImports D start a →
162+
(h : a ∈ D) → b ∈ (D[a]'h).imports →
163+
ReachableViaImports D start b
164+
165+
/-- The closure walk returns exactly the reachable set. Open obligation. -/
166+
theorem transitiveImports.fuel_suffices
167+
(D : DialectMap) (start : DialectName) :
168+
∀ d, ReachableViaImports D start d ↔ d ∈ transitiveImports D start := by
169+
sorry
170+
171+
/-- Operators from `target` and its transitive imports. -/
172+
def extractOpsClosure (dialects : DialectMap) (target : DialectName)
173+
: Array OpInfo := Id.run do
174+
let imports := transitiveImports dialects target
175+
let mut out : Array OpInfo := #[]
176+
for d in dialects.toList do
177+
if imports.contains d.name then
178+
out := out ++ extractOps d
179+
return out
180+
181+
/-! ## Fixpoint -/
182+
183+
/-- One round: add `op.result` for every op whose args are all in `productive`. -/
184+
@[expose] def step (ops : Array OpInfo) (productive : Std.HashSet QualifiedIdent)
185+
: Std.HashSet QualifiedIdent :=
186+
ops.foldl (init := productive) fun acc op =>
187+
if op.args.all acc.contains then acc.insert op.result else acc
188+
189+
theorem step_eq (ops : Array OpInfo) (productive : Std.HashSet QualifiedIdent) :
190+
step ops productive =
191+
ops.foldl (init := productive)
192+
(fun acc op => if op.args.all acc.contains then acc.insert op.result else acc) :=
193+
rfl
194+
195+
/-- Iterate `step`, early-exit on no growth. -/
196+
@[expose] def iterate (ops : Array OpInfo) (productive : Std.HashSet QualifiedIdent)
197+
: Nat → Std.HashSet QualifiedIdent
198+
| 0 => productive
199+
| n + 1 =>
200+
let next := step ops productive
201+
if next.size == productive.size then productive
202+
else iterate ops next n
203+
204+
theorem iterate_zero (ops : Array OpInfo) (s : Std.HashSet QualifiedIdent) :
205+
iterate ops s 0 = s := rfl
206+
207+
theorem iterate_succ
208+
(ops : Array OpInfo) (s : Std.HashSet QualifiedIdent) (n : Nat) :
209+
iterate ops s (n + 1) =
210+
(let next := step ops s
211+
if next.size == s.size then s else iterate ops next n) := rfl
212+
213+
/-- Least productive set. Fuel `ops.size + 1` suffices (one op-result per round). -/
214+
@[expose] def runFixpoint
215+
(trusted : Std.HashSet QualifiedIdent) (ops : Array OpInfo)
216+
: Std.HashSet QualifiedIdent :=
217+
iterate ops trusted (ops.size + 1)
218+
219+
theorem runFixpoint_eq
220+
(trusted : Std.HashSet QualifiedIdent) (ops : Array OpInfo) :
221+
runFixpoint trusted ops = iterate ops trusted (ops.size + 1) := rfl
222+
223+
/-! ## Diagnostics -/
224+
225+
def blockersForCategory
226+
(cat : QualifiedIdent) (ops : Array OpInfo)
227+
(productive : Std.HashSet QualifiedIdent) : Array Blocker := Id.run do
228+
let mut out : Array Blocker := #[]
229+
for op in ops do
230+
if op.result != cat then continue
231+
let unmet := op.args.filter (! productive.contains ·)
232+
out := out.push { opName := op.name, unmetArgCats := unmet }
233+
return out
234+
235+
/-- Run the checker on `target`. Seed: framework atoms; pool: `target` +
236+
transitive imports; output: verdicts for `target`'s declared categories. -/
237+
@[expose] def check (dialects : DialectMap) (target : DialectName) : Result :=
238+
if h : target ∈ dialects then
239+
let d := dialects[target]
240+
let ops := extractOpsClosure dialects target
241+
let cats := declaredCategories d
242+
let productive := runFixpoint trustedCategories ops
243+
let prodOut := cats.filter (productive.contains ·)
244+
let unprodOut := (cats.filter (! productive.contains ·)).map fun c =>
245+
{ category := c, blockers := blockersForCategory c ops productive }
246+
{ dialect := target, productive := prodOut, unproductive := unprodOut }
247+
else
248+
panic! s!"productivity check: dialect {target} not in DialectMap"
249+
250+
/-- `check`'s `productive` field as a `filter`. -/
251+
theorem check_productive_eq
252+
(dialects : DialectMap) (target : DialectName) (h : target ∈ dialects) :
253+
(check dialects target).productive =
254+
(declaredCategories (dialects[target]'h)).filter
255+
((runFixpoint trustedCategories
256+
(extractOpsClosure dialects target)).contains ·) := by
257+
simp [check, h]
258+
259+
/-! ## Pretty-printing -/
260+
261+
private def joinCommaSep (xs : Array QualifiedIdent) : String :=
262+
String.intercalate ", " (xs.toList.map QualifiedIdent.fullName)
263+
264+
private def formatBlocker (b : Blocker) : String :=
265+
s!" op {b.opName} blocked on: {joinCommaSep b.unmetArgCats}"
266+
267+
private def formatUnproductive (u : UnproductiveCategory) : String :=
268+
let header := s!" ✗ {u.category.fullName}"
269+
if u.blockers.isEmpty then
270+
s!"{header}\n (no operator declares this category)"
271+
else
272+
String.intercalate "\n" (header :: u.blockers.toList.map formatBlocker)
273+
274+
def Result.format (r : Result) : String :=
275+
let head := s!"Productivity check for dialect {r.dialect}:"
276+
let noun := if r.productive.size == 1 then "category" else "categories"
277+
let prodLine := s!" ✓ {r.productive.size} {noun} productive"
278+
if r.unproductive.isEmpty then
279+
s!"{head}\n{prodLine}"
280+
else
281+
let unprodHdr := s!" ✗ {r.unproductive.size} unproductive:"
282+
String.intercalate "\n"
283+
(head :: prodLine :: unprodHdr :: r.unproductive.toList.map formatUnproductive)
284+
285+
end Strata.DDM.Productivity
286+
287+
end

0 commit comments

Comments
 (0)