Skip to content

Commit 290aff2

Browse files
respencer-nclclaude
andcommitted
Improve Scala.js validation performance with 4-phase optimization
Phase 0: Replace ParentStack type alias with cached wrapper class that avoids O(N*D) toSeq allocations during AST traversal. Phase 1: Add recursiveFindByType cache to Finder, single-pass handler classification with walkStatements helper, combined SagaStep validation using mutable Sets. Phase 2: Add ValidationMode enum (Full/Quick) and validateStringQuick() that skips expensive streaming analysis and handler classification for interactive feedback. Phase 3: Add IncrementalValidator with context-level FNV-1a fingerprinting and per-context message caching for efficient repeated validation of the same model with small edits. All 800 tests pass across language, passes, riddlLib, commands. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0d4d132 commit 290aff2

12 files changed

Lines changed: 1017 additions & 85 deletions

File tree

CLAUDE.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,3 +768,36 @@ Then add to root aggregation: `.aggregate(..., mymodule, mymoduleJS, mymoduleNat
768768
secret is `HOMEBREW_TAP_SECRET` (not `HOMEBREW_TAP_TOKEN`).
769769
If the dispatch fails with "Parameter token or opts.auth
770770
is required", the secret name is wrong or missing
771+
54. **ParentStack is now a class, not a type alias** — As of
772+
Feb 2026, `ParentStack` is a `final class` wrapping
773+
`mutable.Stack[Branch[?]]` with cached `toParents`. Code
774+
that used `mutable.Stack.empty` for ParentStack must use
775+
`ParentStack.empty`. Same API: push, pop, toParents, head,
776+
headOption, top, isEmpty, nonEmpty, size, find
777+
55. **ValidationMode enum**`ValidationPass` accepts a `mode`
778+
parameter (`Full` or `Quick`). Quick skips `checkStreaming`
779+
and `classifyHandlers` in postProcess. Use
780+
`Pass.quickValidationPasses` or
781+
`ValidationPass.quickCreator()` for interactive validation
782+
56. **IncrementalValidator** — Stateful validator in
783+
`passes/shared/.../IncrementalValidator.scala`. Caches
784+
messages per-Context using FNV-1a fingerprints. API:
785+
`createIncrementalValidator()` and
786+
`validateIncremental(validator, source, origin)` in
787+
RiddlLib/RiddlAPI. Call `validator.reset()` to force full
788+
re-validation
789+
57. **AST.Set shadows scala Set** — Wildcard `import AST.*`
790+
brings in `AST.Set` (RIDDL's Set type) which shadows
791+
`scala.collection.immutable.Set`. Use selective imports
792+
(`import AST.{Context, Domain, ...}`) in files that need
793+
both, or qualify as `scala.collection.immutable.Set`
794+
58. **Contents[?] extension methods don't work** — The opaque
795+
type `Contents` extensions require a concrete type
796+
parameter (`Contents[CV]` where `CV <: RiddlValue`).
797+
`Contents[?]` or `Contents[_ <: RiddlValue]` won't match.
798+
Use a type parameter `[CV <: RiddlValue]` on the method
799+
59. **walkStatements helper in ValidationPass** — Private
800+
method `walkStatements[CV](contents: Contents[CV])(f:
801+
Statement => Unit)` recursively walks Contents descending
802+
into WhenStatement/MatchStatement nesting. Use instead of
803+
creating Finder instances for statement counting/searching

NOTEBOOK.md

Lines changed: 222 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This is the central engineering notebook for the RIDDL project. It tracks curren
66

77
## Current Status
88

9-
**Last Updated**: February 14, 2026
9+
**Last Updated**: February 16, 2026
1010

1111
**Scala Version**: 3.7.4 (overrides sbt-ossuminc's 3.3.7 LTS
1212
default due to compiler infinite loop bug with opaque
@@ -139,6 +139,24 @@ Added `ast2bast(root: Root)` to:
139139
AI-friendly validation pass for MCP server integration. See design
140140
section below.
141141

142+
### 5. Scala.js Validation Performance (DONE)
143+
**Status**: Complete (February 16, 2026)
144+
145+
Four-phase optimization to make validation practical for large
146+
models in Scala.js (browser playground, future LSP):
147+
148+
- **Phase 0**: ParentStack caching — replaced type alias with
149+
wrapper class that caches `toParents` (toSeq) result
150+
- **Phase 1**: ValidationPass micro-optimizations —
151+
`recursiveFindByType` cache, single-pass handler
152+
classification, combined SagaStep validation
153+
- **Phase 2**: `validateStringQuick()``ValidationMode` enum
154+
gates expensive postProcess checks for interactive feedback
155+
- **Phase 3**: `IncrementalValidator` — context-level
156+
fingerprinting and message caching for repeated validation
157+
158+
See session log February 16, 2026 for details.
159+
142160
---
143161

144162
## Blocked Tasks
@@ -422,6 +440,75 @@ suggestions to CLAUDE.md files, create `/release` skill.
422440

423441
---
424442

443+
### February 16, 2026 (Scala.js Validation Performance)
444+
445+
**Focus**: Four-phase optimization to make validation practical
446+
for large RIDDL models in Scala.js. The ossum.ai Playground's
447+
`validateString()` took 168s for reactive-bbq (8,105 lines);
448+
even `getTree()` took 152s due to framework-level overhead.
449+
450+
**Root Cause**: `ParentStack.toParents` (AST.scala) called
451+
`mutable.Stack.toSeq` on every AST node visit — O(N*D)
452+
allocations where N=nodes, D=average depth.
453+
454+
**Work Completed**:
455+
1. **Phase 0: ParentStack caching** — Replaced
456+
`type ParentStack = mutable.Stack[Branch[?]]` with a
457+
`final class ParentStack` that caches the `toSeq` result,
458+
invalidating only on push/pop. Same API surface. Expected
459+
50-70% speedup on all pass traversals.
460+
2. **Phase 1: ValidationPass micro-optimizations** — Added
461+
`recursiveFindByTypeCache` to Finder (mirrors existing
462+
`findByTypeCache`). Added `walkStatements[CV]` helper for
463+
recursive statement walking. Replaced `classifyHandlers`
464+
with single-pass mutable counter walk. Replaced
465+
`validateSagaStep` 4×recursiveFindByType with 2 walks
466+
using mutable Sets.
467+
3. **Phase 2: `validateStringQuick()`** — Added
468+
`ValidationMode` enum (Full/Quick). Quick mode skips
469+
`checkStreaming()` and `classifyHandlers()` in postProcess.
470+
Added `quickValidationPasses` to Pass companion.
471+
Exposed via RiddlLib trait, RiddlAPI JS facade, and
472+
TypeScript declarations.
473+
4. **Phase 3: Incremental validation** — Created
474+
`ContextFingerprint` (FNV-1a 64-bit hashing of Context
475+
source spans, cross-platform). Created
476+
`IncrementalValidator` — stateful validator that caches
477+
messages per-Context, re-validates only changed Contexts.
478+
Conservative: falls back to full validation when >50%
479+
changed. Exposed via `createIncrementalValidator()` and
480+
`validateIncremental()` in RiddlLib/RiddlAPI/TypeScript.
481+
482+
**Also fixed**: Pre-existing infix method warnings in
483+
PassTest.scala (`must be(x)``.must(be(x))`), updated
484+
`mutable.Stack.empty``ParentStack.empty` in test code.
485+
486+
**Test Results**: 800 tests across language (280), passes
487+
(270), riddlLib (12), commands (238) — all passing on JVM.
488+
Full `sbt test` (JVM + JS + Native) passes.
489+
490+
**Files Created**:
491+
- `passes/shared/.../ContextFingerprint.scala`
492+
- `passes/shared/.../IncrementalValidator.scala`
493+
494+
**Files Modified**:
495+
- `language/shared/.../AST.scala` — ParentStack class
496+
- `language/shared/.../Finder.scala` — recursiveFindByType
497+
cache
498+
- `passes/shared/.../Pass.scala` — quickValidationPasses
499+
- `passes/shared/.../validate/ValidationPass.scala`
500+
ValidationMode, walkStatements, optimized classifyHandlers
501+
and validateSagaStep
502+
- `passes/jvm-native/.../PassTest.scala` — ParentStack.empty,
503+
infix fixes
504+
- `riddlLib/shared/.../RiddlLib.scala` — validateStringQuick,
505+
createIncrementalValidator, validateIncremental, doValidate
506+
refactor
507+
- `riddlLib/js/.../RiddlAPI.scala` — JS facades
508+
- `riddlLib/js/types/index.d.ts` — TypeScript declarations
509+
510+
---
511+
425512
### February 14, 2026 (RiddlResult ADT)
426513

427514
**Focus**: Add cross-platform `RiddlResult[T]` result type to
@@ -1923,6 +2010,140 @@ Tool(
19232010

19242011
---
19252012

2013+
## Scala.js Validation Performance (from ossum.ai playground)
2014+
2015+
**Filed:** 2026-02-16
2016+
**Context:** The ossum.ai RIDDL Playground loads models from
2017+
riddl-models via `.bast` files, converts them to source with
2018+
`root2RiddlSource()`, and optionally validates with
2019+
`validateString()`. For the `reactive-bbq` model (8,105 lines /
2020+
213KB after flattening), `validateString()` takes **168 seconds**
2021+
in the browser (Scala.js) vs seconds on JVM. This makes
2022+
interactive validation impractical for large models.
2023+
2024+
### Benchmark (reactive-bbq, Chrome/V8)
2025+
2026+
| Operation | Time |
2027+
|-----------|------|
2028+
| `bast2FlatAST()` | 57ms |
2029+
| `root2RiddlSource()` | 12ms |
2030+
| `parseString()` | 24ms |
2031+
| `getTree()` | 152,714ms |
2032+
| `validateString()` | 168,673ms |
2033+
2034+
**Note:** `getTree()` runs only `TreePass` (not the full
2035+
validation pipeline), yet still takes 2.5 minutes. This is
2036+
unexpectedly slow and warrants separate investigation — the
2037+
tree pass should be lightweight.
2038+
2039+
### Identified Bottlenecks in ValidationPass
2040+
2041+
#### 1. Double iteration in `classifyHandlers()`
2042+
2043+
**Location:** `ValidationPass.scala` ~lines 1260-1299
2044+
2045+
For every handler, `recursiveFindByType[Statement]` collects
2046+
all statements, then the list is iterated **twice** — once
2047+
with `.count()` for executable statements, once with
2048+
`.count()` for prompt statements. A single-pass accumulator
2049+
would halve this work.
2050+
2051+
**Fix:** Replace two `.count()` calls with one `.foreach`
2052+
that increments both counters:
2053+
2054+
```scala
2055+
var executableCount = 0
2056+
var promptCount = 0
2057+
allStatements.foreach {
2058+
case _: TellStatement | _: SendStatement |
2059+
_: MorphStatement | _: SetStatement |
2060+
_: BecomeStatement | _: ErrorStatement |
2061+
_: CodeStatement =>
2062+
executableCount += 1
2063+
case _: PromptStatement =>
2064+
promptCount += 1
2065+
case _ => ()
2066+
}
2067+
```
2068+
2069+
**Estimated impact:** ~15-20% of postProcess time
2070+
2071+
#### 2. Redundant `symbols.parentsOf()` in streaming
2072+
2073+
**Location:** `StreamingValidation.scala` ~line 44
2074+
2075+
`symbols.parentsOf(connector)` is called for every connector
2076+
without caching. Additionally, the reverse adjacency map is
2077+
built in a separate pass instead of simultaneously with the
2078+
forward adjacency, and two full BFS traversals run (sources→
2079+
sinks, then sinks→sources) when one bidirectional pass would
2080+
suffice.
2081+
2082+
**Fix:** Cache `parentsOf()` results in a local map. Build
2083+
forward and reverse adjacency simultaneously.
2084+
2085+
**Estimated impact:** ~10-15%
2086+
2087+
#### 3. Four tree walks per SagaStep
2088+
2089+
**Location:** `ValidationPass.scala` ~lines 976-1010
2090+
2091+
Each SagaStep creates two Finders (do/undo), walks each
2092+
**twice** (once for `TellStatement`, once for
2093+
`SendStatement`), creates intermediate `Seq`s, converts to
2094+
`Set`s. Could be 2 walks instead of 4 by collecting both
2095+
statement types in a single pass.
2096+
2097+
**Estimated impact:** ~8-12%
2098+
2099+
#### 4. Finder cache is per-instance
2100+
2101+
**Location:** `Finder.scala` ~lines 27-64
2102+
2103+
Each `Finder(container.contents)` creates a fresh
2104+
`findByTypeCache`. Across hundreds of handlers creating new
2105+
Finders, no cache reuse occurs. A shared cache keyed by
2106+
container identity could help.
2107+
2108+
### Micro-optimization Summary
2109+
2110+
These fixes sum to roughly **20-30% improvement** (~30-50s
2111+
off the 3-minute Scala.js runtime). Meaningful but doesn't
2112+
solve the fundamental 10-50x Scala.js vs JVM gap for
2113+
allocation-heavy compute.
2114+
2115+
### Architectural Approaches (Higher Impact)
2116+
2117+
1. **Pre-compute validation results into BAST** — Store
2118+
validation messages alongside `.bast` files at build time.
2119+
The playground would just display them with zero compute.
2120+
This is the highest-impact, lowest-risk change.
2121+
2122+
2. **Incremental validation** — Only re-validate the changed
2123+
definition and its dependents, not the entire model. This
2124+
is how language servers (LSP) stay fast. High effort but
2125+
would make the playground truly interactive for any size
2126+
model.
2127+
2128+
3. **`validateStringLite()` for JS** — A lighter validation
2129+
pass that skips expensive checks (streaming analysis, saga
2130+
compensation, handler completeness) that matter less in a
2131+
browser playground context. Medium effort, could bring
2132+
large-model validation under 30 seconds.
2133+
2134+
### Current ossum.ai Workaround
2135+
2136+
The playground uses a tiered strategy:
2137+
- `parseString()` on main thread (~24ms) for every keystroke
2138+
- Sources under 10KB: auto-validate in Web Worker on
2139+
keystroke (debounced)
2140+
- Sources over 10KB: manual "Validate" button triggers Web
2141+
Worker; page stays responsive while validation runs in
2142+
background
2143+
- BAST-loaded models: skip validation entirely (pre-validated)
2144+
2145+
---
2146+
19262147
## Git Information
19272148

19282149
**Branch**: `development`

language/shared/src/main/scala/com/ossuminc/riddl/language/AST.scala

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -850,21 +850,55 @@ object AST:
850850
end Parents
851851

852852
/** A mutable stack of Branch[?] for keeping track of the parent hierarchy.
853-
* Contains only Branch (Definition) nodes - Include nodes are tracked separately via includeContext in Pass.
854-
*/
855-
type ParentStack = mutable.Stack[Branch[?]]
856-
857-
/** Extension methods for the ParentStack type */
858-
extension (ps: ParentStack)
859-
/** Convert the mutable ParentStack into an immutable Parents Seq */
860-
def toParents: Parents = ps.toSeq
861-
end extension
853+
* Contains only Branch (Definition) nodes - Include nodes are tracked
854+
* separately via includeContext in Pass.
855+
*
856+
* Caches the `toParents` (toSeq) result for performance. The cache is
857+
* invalidated on push/pop. This avoids O(N*D) allocations during AST
858+
* traversal where N is the number of nodes and D is average depth.
859+
*/
860+
final class ParentStack private (
861+
private val stack: mutable.Stack[Branch[?]]
862+
):
863+
private var cachedSeq: Parents | Null = null
864+
865+
def push(item: Branch[?]): Unit =
866+
stack.push(item)
867+
cachedSeq = null
868+
end push
869+
870+
def pop(): Branch[?] =
871+
cachedSeq = null
872+
stack.pop()
873+
end pop
874+
875+
/** Convert the mutable ParentStack into an immutable Parents Seq.
876+
* Result is cached until the next push or pop.
877+
*/
878+
def toParents: Parents =
879+
if cachedSeq == null then cachedSeq = stack.toSeq
880+
cachedSeq.nn
881+
end toParents
882+
883+
inline def head: Branch[?] = stack.head
884+
inline def headOption: Option[Branch[?]] = stack.headOption
885+
inline def top: Branch[?] = stack.top
886+
inline def isEmpty: Boolean = stack.isEmpty
887+
inline def nonEmpty: Boolean = stack.nonEmpty
888+
inline def size: Int = stack.size
889+
890+
/** Find the first element matching the predicate (top to bottom). */
891+
inline def find(p: Branch[?] => Boolean): Option[Branch[?]] =
892+
stack.find(p)
893+
end ParentStack
862894

863-
/** A Companion to the ParentStack class */
895+
/** Companion to the ParentStack class */
864896
object ParentStack:
865-
/** @return an empty ParentStack */
866-
def empty[CV <: RiddlValue]: ParentStack = mutable.Stack.empty[Branch[?]]
867-
def apply(items: Branch[?]*): ParentStack = mutable.Stack(items: _*)
897+
/** @return an empty ParentStack */
898+
def empty[CV <: RiddlValue]: ParentStack =
899+
new ParentStack(mutable.Stack.empty[Branch[?]])
900+
def apply(items: Branch[?]*): ParentStack =
901+
new ParentStack(mutable.Stack(items*))
868902
end ParentStack
869903

870904
type DefinitionStack = mutable.Stack[Definition] // TODO: Make this opaque some day

0 commit comments

Comments
 (0)