Skip to content

feat(model): persistent semantic model per workspace, invalidated per document - #316

Merged
HuiJun merged 16 commits into
developfrom
feature/persistent-semantic-model
Sep 15, 2026
Merged

HuiJun merged 16 commits into
developfrom
feature/persistent-semantic-model

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

What and why

Implements "Approach 1 — a persistent semantic model, invalidated per document" (§3, 3.1–3.4) of docs/project/large-model-scaling-design.md. Every edit used to clear the whole workspace — diagCache, the reverse-reference index — and the next request started from a fresh resolve.Resolver and semantics.Model, so a keystroke in a two-line file beside a large model cost a re-analysis of the model.

  • One resolver and one model per model.Workspace (workspace.go: semanticsLocked, made on first use, handed to every passes.Context the workspace builds through passes.AnalyzeShared / Context.Share). A context built outside a workspace still constructs its own; CLI paths and tests are unchanged.
  • Document-granular dependency relation, recorded rather than computed: symbols.Index reports every read to a ReadRecorder (a name looked up, a namespace enumerated or its children read, a document's root or kind, the whole table) and every write to a Changes set (symbols/changes.go, index.go, layer.go). The resolver (resolve/frames.go) keeps a frame per document — what was memoized while that document was analyzed, and what it read — and records a dependency on another document when a resolution returned that document's symbol (Resolver.foundDepend(sym.DocName)), when it imports a namespace the other contributes to, or when both contribute to one namespace. Index.TakeChanges + Resolver.Invalidate compute the transitive closure of the frames a change reaches.
  • Invalidation by owning document (workspace.go: invalidateLockedResolver.Invalidate): drops the changed document's frame and its dependents', their memo entries (resolver, semantic model and argument typer all journal through the resolver's existing JournalNew, now a per-table ledger), their diagnostics and their reverse references. SetConformanceMode still invalidates everything. The semantic model's memoizing methods own their entries with defer m.own(sym).LeaveDoc() before the cache lookup, so a hit also records that the reader depends on the entry's owner — needed for compound-keyed memos such as composed[(S, T)].
  • Cached gather + per-request judge for the workspace-wide passes (passes/gathers.go, oosem_gather.go, mosa_gather.go, identity_gather.go, and semantics/coherent_quantity.go via Model.shared): each document's OOSEM/MOSA/identity facts are gathered once into passes.Gathers, owned by the workspace, regathered for the changed documents only (Gathers.Regather), and each analysis judges over the union. Unions live in named resolver frames so that a regather that moves a shared judgment's input drops its readers. Populated gathers serve concurrent readers under a mutex.
  • LSP refresh sweep (internal/lsp/files.go): refreshOpenDiagnostics republishes from the cache for open documents outside the changed one's dependent set.
  • REPL: acceptFrom/dropStaleDebugSessions/keepIdentitiesOf needed no change; the transcript is one document and a submission that only adds an unrelated member still leaves the debugger running (internal/repl tests unchanged). The file-load path (%load, sysml -validate a b) is being changed separately to one document per file and is not touched here.

Where the implementation differs from the design: §3.2 suggests the REPL treating each top-level submission as its own document. That was not needed for the contract stated (the debugger's supersession is declaration-granular and already independent of the workspace's caching), so it is not done here.

Which path pays the bookkeeping, and which does not

Tracking is always on for a workspace, as the design states. Every memoized read records "the current document depends on this entry's owner" — about 25 M reads × ~8 ns on the 200-satellite constellation — so a one-shot sysml -validate, which analyzes through a REPL session's workspace, pays it with nothing to invalidate:

sysml -validate -memstats, 200 satellites, one file, 3 runs develop this branch
wall 1.85–1.96 s 2.19–2.25 s
allocated 738 MiB / 10.97 M allocations 767 MiB / 10.98 M allocations
peak RSS (/usr/bin/time) 421 MiB 428 MiB

At 1 600 satellites: 17.7 s / 5.5 GiB → 20.5 s / 5.8 GiB. This is a measured regression of the batch path, reported rather than hidden. What removes it: #312 (perf/parallel-batch-validation, stacked on #309) — its Workspace.DiagnosticsAll runs each uncached document through analyze(name, doc, batch)passes.AnalyzeInBatch, which builds a private passes.Context per document (NewContextWithOptions: its own resolver and model over the read-only index) and never touches the workspace's persistent resolver, so the batch path there records nothing and pays none of this. Verified against #312's internal/core/model/batch.go and passes.Context.Batch.

Follow-up, not done here: Context.Batch is where #312's workers should receive this branch's passes.Gathers, so that the audits gather each document once for the whole batch rather than once per worker. It is described in docs/internals/performance.md and the stress-test record's further work.

Overlap to reconcile on whichever lands second: #309/#312 also edit workspace.go (Open/Update/SetOnDisk reindex via AddBuiltDocument; diagnosticsLocked delegating to analyze). This branch is based on develop, not on either.

How it was verified

Incremental equals fresh. TestIncrementalEqualsFresh (internal/core/model/incremental_test.go) replays a scripted sequence and a seeded random one — edit a document to a mutation or back to an earlier version, close, open — over the fixture sets, and TestIncrementalEqualsFreshCorpora over the four OMG corpora; after every step it compares diagnostics, the resolution of every written reference and the reverse references with a workspace built fresh from the same documents. Both run in go test ./... (the corpus variant skips without the corpora, fails with the require variables set).

Focused tests added: resolver frames, ledgers and transitive invalidation (resolve/frames_test.go); index read recording and change sets (symbols/changes_test.go); gathers gather each document once, regather only the changed one, propagate a union change to its readers, serve concurrent analyses (passes/gathers_test.go); workspace edits across documents, union-dependent diagnostics, retained state (model/workspace_test.go, stressmodel/satnet_test.go).

Measurements (Intel Xeon Platinum 8559C, 8 CPUs, 31 GiB RAM, Go 1.25.0, Linux; go test -run '^$' -bench ... -benchmem -benchtime=5x -count=3 ./internal/stressmodel, medians; "develop" is bcc6b13e0 built on the same machine):

develop this branch
BenchmarkEditBeside/32 48 ms, 22 MiB/op 0.82 ms, 0.31 MiB/op
BenchmarkEditBeside/128 189 ms, 83 MiB/op 2.5 ms, 0.64 MiB/op
BenchmarkEditBeside/512 861 ms, 327 MiB/op 8.7 ms, 2.0 MiB/op
BenchmarkEditImported/512 (6 files; edit the library, then every file's diagnostics) 8.78 s, 2.77 GiB 5.26 s, 1.17 GiB
BenchmarkLoadFiles/512 (6 files through one workspace) 9.20 s, 3.05 GiB 5.26 s, 1.56 GiB
1 600 satellites in 34 files through one workspace, open + analyze all 126.5 s 18.3 s
BenchmarkLoad/512, one file 5.13 s, 254 MiB held 5.99 s, 478 MiB held
live heap, 1 000 edits beside 32 satellites (TestEditsHoldNoStaleState) 67.2 MB after the 1st edit → 70.2 MB after the 1 000th

The design's target (512 satellites: ~1.53 s → < 50 ms) is met; develop on this machine measures 861 ms rather than the 1.53 s in the record, so both columns above were taken here. Editing the imported library costs one analysis of the model (the same 5.26 s as loading the six files). The 34-file figure is the §5 workload: the three audits gathered every document once per document analyzed; now once. A loaded workspace holds about twice the heap it did (the memo tables that used to be discarded after each analysis).

Gates:

$ gofmt -l .
$ go build ./... && go vet ./...
$ go test -count=1 ./...            # all ok
$ go test -race -count=1 ./...      # all ok
$ make lint                         # ✓ Lint passed
$ make docs-check                   # OK
$ make docs-counts                  # ✓ Documentation counts and refereed figures are current (8,369 → 8,391 tests)
$ ./scripts/download-training-examples.sh && ./scripts/download-pilot-corpora.sh
$ OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 \
    go test -count=1 -v ./internal/core/model -run 'TestTrainingExamples|TestPilotCorpora'
    training_examples_test.go:60: 100/100 training files clean
    pilot_corpora_test.go:78: kerml-examples: 55/58 pilot corpus files clean
    pilot_corpora_test.go:78: sysml-examples: 95/99 pilot corpus files clean
    pilot_corpora_test.go:78: sysml-validation: 56/56 pilot corpus files clean

training_examples_expected.txt and the pilot expectations are untouched.

Checklist

  • make test and make lint pass locally
  • Tests added or updated for the change
  • Documentation extended where it already covers the surface (docs/internals/performance.md, docs/project/satellite-network-stress-test.md)
  • Changelog entry added as changes/unreleased/persistent-semantic-model.performance.md
  • baselines regenerated and make docs-counts run (test count moved)
  • No internal work-item labels in the body, docs, or changelog

devin-ai-integration Bot and others added 8 commits September 15, 2026 07:59
…validated per document

Co-Authored-By: jason.han <hanhuijun@gmail.com>
Co-Authored-By: jason.han <hanhuijun@gmail.com>
OOSEMMethodPass, MOSAPass and IdentityMetadataPass gather each workspace
document once into a workspace-owned Gathers and judge over the union, so
an analysis costs one gather per document rather than one per document per
analyzed document. A change re-gathers the changed document only and drops
the judgments whose union answer moved.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
…tered frames

A frame's journal held one closure per memoized entry; it now holds one ledger
per memo table with the keys written there, so dropping a document deletes its
entries without a call each. Entering a document's frame from another records
the dependency once per recently entered document rather than once per read.
Direct-child lookups read the namespace they enumerate, and the identity gather
spells its facts when judged rather than when gathered.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
… loading and editing it

Split writes the library, one document per plane and the constellation joining
them; BenchmarkLoadFiles analyzes the split through one workspace and
BenchmarkEditImported edits the library every plane imports.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
Co-Authored-By: jason.han <hanhuijun@gmail.com>
…ints

Co-Authored-By: jason.han <hanhuijun@gmail.com>
…rnaling

Co-Authored-By: jason.han <hanhuijun@gmail.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration
devin-ai-integration Bot marked this pull request as ready for review September 15, 2026 11:20
devin-ai-integration[bot]

This comment was marked as resolved.

…cument

bodyApplicationOf wrote bodyIndexed and bodyApplications outside any frame,
so every replacement of a document with collection bodies kept its previous
root scope and body expressions reachable. scalarTable memoized the stdlib
scalar symbols once per model with nothing to drop it, so a document
declaring a scalar name kept its stale symbols after it changed. Both now
join the resolver's document frames: the body index is journaled under the
document holding the body, and the scalar table is built in its shared
frame, dropped with its readers when a declaring document changes.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Merging with perf/parallel-batch-validation (#312)

A trial merge of origin/perf/parallel-batch-validation into this branch conflicts in nine files. Whichever lands second can resolve it as follows; on the resolved tree go build ./... && go test ./internal/core/model/... ./internal/repl/... pass (also -race for the batch and incremental tests), with the editor path on the persistent model and DiagnosticsAll on private per-worker contexts.

With #312's sysml -validate going through DiagnosticsAll, the batch path no longer touches the persistent model, so the tracking overhead reported in the description applies to the editor path only.

devin-ai-integration Bot and others added 3 commits September 15, 2026 12:01
Co-Authored-By: jason.han <hanhuijun@gmail.com>
A change to a gather's judgment names carries no index registration, so it
need not drop the frames that enumerated the whole name table nor rebuild
the suggestion table; both now survive until a namespace or document
actually changes. Frames that read the whole table keep recording the
judgment names they read, so a judgment change still reaches them.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
…-semantic-model

Co-Authored-By: jason.han <hanhuijun@gmail.com>

# Conflicts:
#	README.md
#	docs/project/spec-compliance.md
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 2 commits September 15, 2026 14:19
…declaring document

A reader served a cached member-source or lookup-order closure from another
document recorded no dependency on the document declaring the symbol, so
editing that document left the reader's diagnostics and derived state
cached. Enter the declaring document's frame before the memo lookup, as the
other symbol-keyed memos do.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
…-semantic-model

Co-Authored-By: jason.han <hanhuijun@gmail.com>

# Conflicts:
#	README.md
devin-ai-integration[bot]

This comment was marked as resolved.

…-semantic-model

Co-Authored-By: jason.han <hanhuijun@gmail.com>

# Conflicts:
#	README.md
#	docs/project/spec-compliance.md
…-semantic-model

Co-Authored-By: jason.han <hanhuijun@gmail.com>

# Conflicts:
#	README.md
#	docs/project/spec-compliance.md

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Devin Review

Comment thread internal/core/passes/identity_gather.go
@HuiJun
HuiJun merged commit 78e19f6 into develop Sep 15, 2026
12 checks passed
@HuiJun
HuiJun deleted the feature/persistent-semantic-model branch September 15, 2026 18:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant