diff --git a/AGENTS.md b/AGENTS.md index a61e3aad24..9a44cee3ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,6 +192,26 @@ Then update `docs/project/spec-compliance.md` mapping: semantic rule → impleme --- +### 6.1 Bumping the extension-library pin + +The vendored `OpenSysML Libraries` (`scripts/extension-libraries-pin.sh`, synced by +`scripts/sync-extension-libraries.sh`) are an input to gates far from `internal/workspace/libs`, and +the full CI tier is expensive, so run them locally before the PR, every time the pin moves: + +1. `go generate ./internal/workspace/libs` — `stdlib.snapshot` embeds the libraries; commit it. +2. `make extension-libraries-pin-check` — Java-free, about a minute: the sync check, the snapshot, + `tests/hygiene TestEngineContractCoversEveryBoundName` (a new binding in + `internal/ir/queryplan/compiler.go` needs an `engine-contract.json` entry upstream, in the same + library PR as the declaration), the provenance guards of both pilot-differential baselines, and + the REPL/query tests (a new library name enters the REPL's "did you mean" suggestions and can + collide with a fixture). +3. If a provenance guard fails, the baselines record the previous library digest: run + `go run -C tools ./cmd/pilot-diff` and `./cmd/pilot-apijson-diff` (the pilot toolchain is in the + blueprint), adjudicate every moved entry against the committed baseline, write the adjudication + into `docs/project/pilot-differential.md` / `api-json-differential.md`, then re-record with + `-update` and run `make docs-counts`. A library change that adds declarations always moves + `pilotLibraryElements`; anything else moving is an implementation difference to explain. + ## 7. Code Style & Commits - **Formatting:** `gofmt` is mandatory (CI-enforced). Follow *Effective Go*. Document exported types/functions. diff --git a/Makefile b/Makefile index 38440a0153..cb921dcd7e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build docker vulncheck sca-tools audit audit-npm audit-python audit-rust audit-java extension-libraries-check extension-libraries-sync build-sysml build-re build-wasm-re build-lsp build-grpc build-mcp build-engine build-core build-syntax build-sysml-wasm build-release-wasm build-wasm build-wasm-wasip1 build-wasm-js wasm-check static-check windows-versioninfo-check man man-check install-tree pgo-profile conformance conformance-pkg conformance-mcp conformance-rust conformance-julia conformance-matlab test test-shard coverage coverage-shard coverage-merge lint clean install help ontology-table ontology-table-check metaclass-graph metaclass-graph-check python-metamodel python-metamodel-check fuml-expected python-test python-coverage scripts-coverage node-coverage vscode-coverage jupyterlab-coverage mcp-npm-coverage python-install jupyter-kernel-install jupyter-kernel-test proto proto-buf python-proto proto-ts proto-rust proto-lint proto-breaking jupyterlab-build jupyterlab-test jupyterlab-install vscode-grammar vscode-build vscode-package docs docs-install docs-serve docs-engine-assets docs-landing-assets docs-counts diagnostics-catalogue diagnostics-check docs-check changelog-check changelog-render self-model mcp-registry-check +.PHONY: all build docker vulncheck sca-tools audit audit-npm audit-python audit-rust audit-java extension-libraries-check extension-libraries-sync extension-libraries-pin-check build-sysml build-re build-wasm-re build-lsp build-grpc build-mcp build-engine build-core build-syntax build-sysml-wasm build-release-wasm build-wasm build-wasm-wasip1 build-wasm-js wasm-check static-check windows-versioninfo-check man man-check install-tree pgo-profile conformance conformance-pkg conformance-mcp conformance-rust conformance-julia conformance-matlab test test-shard coverage coverage-shard coverage-merge lint clean install help ontology-table ontology-table-check metaclass-graph metaclass-graph-check python-metamodel python-metamodel-check fuml-expected python-test python-coverage scripts-coverage node-coverage vscode-coverage jupyterlab-coverage mcp-npm-coverage python-install jupyter-kernel-install jupyter-kernel-test proto proto-buf python-proto proto-ts proto-rust proto-lint proto-breaking jupyterlab-build jupyterlab-test jupyterlab-install vscode-grammar vscode-build vscode-package docs docs-install docs-serve docs-engine-assets docs-landing-assets docs-counts diagnostics-catalogue diagnostics-check docs-check changelog-check changelog-render self-model mcp-registry-check # Version information # Only release tags describe a build; the moving `nightly` tag is not a version. @@ -451,6 +451,14 @@ extension-libraries-check: ## Check the vendored extension libraries match their extension-libraries-sync: ## Re-vendor the extension libraries from the pinned upstream commit ./scripts/sync-extension-libraries.sh +extension-libraries-pin-check: ## After moving the extension-library pin: every Java-free gate the full tier runs over the vendored libraries + ./scripts/sync-extension-libraries.sh --check + go run -C $(TOOLS_DIR) ./gen/snapshot -check + go test -count=1 ./tests/hygiene -run 'TestEngineContract' + go test -C $(TOOLS_DIR) -count=1 ./referee/diff ./referee/apijson -run 'TestCommittedBaselineStatesThisRepositorysProvenance|TestProvenance' + go test -count=1 ./internal/frontend/repl ./internal/workspace/libs ./internal/ir/queryplan ./internal/doc/queryexec + @echo "✓ the vendored extension libraries pass every pin gate" + stdlib-snapshot: ## Regenerate the embedded snapshot of the bundled library after editing $(LIBS_DIR)/stdlib go generate ./$(LIBS_DIR) diff --git a/changes/unreleased/differ.added.md b/changes/unreleased/differ.added.md new file mode 100644 index 0000000000..8fca22aa2c --- /dev/null +++ b/changes/unreleased/differ.added.md @@ -0,0 +1 @@ +- `DocumentQueries::Differ(left, right, key, column)`: a keyed value comparison of two projected tables — one row (`key`, `left`, `right`) per key value on both sides whose `column` cells differ. A side that projects no such column is the path error `unknown-column`; a key value on two rows of one side is the model error `duplicate-key`, every finding kept. diff --git a/cmd/sysml-grpc/access_test.go b/cmd/sysml-grpc/access_test.go index 20106aa86e..548a3b2108 100644 --- a/cmd/sysml-grpc/access_test.go +++ b/cmd/sysml-grpc/access_test.go @@ -268,9 +268,7 @@ func TestGRPCRobustnessAuthStartup(t *testing.T) { t.Run("health binds the same host", func(t *testing.T) { s := startService(t, "-port", "0", "-health-port", freePort(t)) s.address(30 * time.Second) - if logs := s.logs.String(); !strings.Contains(logs, "Health check server listening\" addr=localhost:") { - t.Errorf("the health listener did not bind loopback:\n%s", logs) - } + s.waitLog("Health check server listening\" addr=localhost:", 30*time.Second) }) t.Run("non-loopback without a token is refused", func(t *testing.T) { s := startService(t, "-host", "0.0.0.0", "-port", "0", "-health-port", "0") diff --git a/cmd/sysml-grpc/lifecycle_test.go b/cmd/sysml-grpc/lifecycle_test.go index fb11c3ecfb..5f948b2177 100644 --- a/cmd/sysml-grpc/lifecycle_test.go +++ b/cmd/sysml-grpc/lifecycle_test.go @@ -181,6 +181,19 @@ func (s *service) address(timeout time.Duration) string { } } +// waitLog waits until the service has logged a line containing want: the health +// listener announces itself from its own goroutine, after the main address. +func (s *service) waitLog(want string, timeout time.Duration) { + s.t.Helper() + deadline := time.Now().Add(timeout) + for !strings.Contains(s.logs.String(), want) { + if time.Now().After(deadline) { + s.t.Fatalf("the service did not log %q within %s\nlogs: %s", want, timeout, s.logs.String()) + } + time.Sleep(10 * time.Millisecond) + } +} + // waitStatus waits for the process to end and returns its exit status. A // process still running when the timeout expires is a leak, and a failure. func (s *service) waitStatus(timeout time.Duration) int { diff --git a/docs/internals/design/query-outcomes.md b/docs/internals/design/query-outcomes.md index a1a02423e2..8c156e74de 100644 --- a/docs/internals/design/query-outcomes.md +++ b/docs/internals/design/query-outcomes.md @@ -28,6 +28,7 @@ to `∅` is the ambiguity QVTS exists to remove. | `Named` | the element | `unknown-element`: the longest prefix that resolves, the segment it lacks, the nearest spellings; an alias that denotes nothing | `ambiguous-element`: the elements, by qualified name; the finding names the owning namespace | | `WhereType`, `WhereMetadata` | the selected rows | `unknown-classification` | — | | `RelatedElements` | the neighbours, possibly none | `unknown-relationship` | — | +| `Differ` | the `(key, left, right)` rows whose column differs, possibly none | `unknown-column`: the side (`Parameter`) that projects no such column (`Property`), its columns (`Candidates`) | `duplicate-key`: one `duplicateKey` finding per row repeating a key value on one side, `multiplicity` where a key cell is not single-valued | | `Project`, `WhereFeature` on a feature the element lacks | an empty cell, with `tolerate = true` | `missing-feature`: the element (`Target`), the feature (`Property`), the features it does have (`Candidates`); `unknown-property` when no row carries it | — | | `Project`, `WhereFeature` on a metadata path (`Tag::key`) the element lacks | *today an empty cell* | *to do:* decide whether an absent annotation is a path error | — | | `Project` on a `[1]` feature with two values | *today the first value* | — | *to do:* `multiplicity` | diff --git a/docs/manual/query-cookbook.md b/docs/manual/query-cookbook.md index 31a2ea94a9..e15884c504 100644 --- a/docs/manual/query-cookbook.md +++ b/docs/manual/query-cookbook.md @@ -1354,6 +1354,40 @@ $ sysml cookbook.sysml -run-query "Cookbook::VerifiedButUnsatisfied root=Cookboo to be unprojected or to project the same columns, and is a typed error otherwise, since its rows share one table. +### Keyed comparison: `Differ` + +`Except` and `Union` identify rows, so a value that changed on an element present on +both sides is invisible to them. `Differ(left, right, key, column)` compares two projected +tables by a key column: one row per key value present on both sides whose `column` cells +differ, with the columns `key`, `left` and `right` — the key, then the cell of each side. +Cells compare as sequences under KerML `==` (elements by identity, numbers by value), so a +many-valued cell differs when its values or their order do; keys match under the same +equality, so an integer `1` and a real `1.0` are one key. Keys on one side only are not +rows here; `Except` reports those. A `Tree`-arranged left side keeps its depths. + +```sysml +calc def PartMasses :> Query { + in root : Element; + Project( + source = WhereType(source = OwnedElements(source = root), type = "PartUsage"), + properties = ("name", "mass") + ) +} + +calc def ChangedMasses :> Query { + in before : Element; + in after : Element; + Differ(left = PartMasses(root = before), right = PartMasses(root = after), key = "name", column = "mass") +} +``` + +Both sides must project `key` and `column`; a side that does not is the typed error +`unknown-column`, a path error at `Differ(key, column)`. A key named `left` or `right` +would collide with the result's side columns and is an `invalid-argument`. A key value carried by two rows +of one side, or a key cell that is not single-valued, is the model error `duplicate-key` +(`duplicateKey` / `multiplicity` findings, one per offending row, every row kept) rather +than a silent choice between them. + ## Requirement hierarchy A requirement tree is the requirement definitions and usages under a root — diff --git a/docs/project/api-json-differential-baseline.json b/docs/project/api-json-differential-baseline.json index cea543f85d..553bd061af 100644 --- a/docs/project/api-json-differential-baseline.json +++ b/docs/project/api-json-differential-baseline.json @@ -86,7 +86,7 @@ "dir": "internal/workspace/libs/stdlib/OpenSysML Libraries", "origin": "ours", "files": 16, - "digest": "sha256:29a3bf0fb7bb630a1457768ba774a6baf042469b071c8bda677117ed65d98c65" + "digest": "sha256:4f350c5a695cda953e0fb8f1b9b273c998f57480b9770bf063b6ca51a96cdf13" } ], "recorded": "2026-10-29" @@ -111,7 +111,7 @@ "oursUnconvertible": 0, "oursColliding": 28, "pilotFailed": 59, - "pilotLibraryElements": 37613, + "pilotLibraryElements": 38423, "elements": { "matched": 92426, "oursOnly": 18479, @@ -371,7 +371,7 @@ "oursRefused": 0, "pilotFailed": 0, "oursColliding": 3, - "pilotLibraryElements": 15648, + "pilotLibraryElements": 15963, "elements": { "matched": 26937, "oursOnly": 6046, @@ -443,7 +443,7 @@ "files": 11, "oursRefused": 0, "pilotFailed": 0, - "pilotLibraryElements": 17209, + "pilotLibraryElements": 17704, "elements": { "matched": 7199, "oursOnly": 920, diff --git a/docs/project/api-json-differential.md b/docs/project/api-json-differential.md index a38d6d1c43..87750b90cc 100644 --- a/docs/project/api-json-differential.md +++ b/docs/project/api-json-differential.md @@ -242,9 +242,18 @@ The pilot's export failed on 59 files (`pilotFailed`: its own export throws on a | `examples/pilot-corpora/kerml-examples` | 58 | 6399 | 1166 | 1842 | 73725 | 2504 | 63918 | 5784 | | `examples/spec-examples` | 511 | 7074 | 990 | 2656 | 76404 | 1567 | 74068 | 5295 | | `tests/testdata` | 40 | 1460 | 175 | 511 | 16445 | 428 | 15857 | 1042 | -| `examples` | 53 | 26937 | 6046 | 15045 | 332702 | 8290 | 322123 | 19997 | +| `examples` | 53 | 26937 | 6046 | 15045 | 332672 | 8320 | 322123 | 19997 | | `tools/referee/diff/testdata` | 4 | 53 | 0 | 0 | 555 | 1 | 454 | 41 | -| `docs/manual/examples` | 11 | 7199 | 920 | 2110 | 88779 | 2034 | 89601 | 4816 | +| `docs/manual/examples` | 11 | 7199 | 920 | 2110 | 88741 | 2072 | 89601 | 4816 | + +**Defaulted parameters of an invocation** (re-recorded with the extension libraries at +`DocumentQueries::WhereType(…, exact = false)` and `Differ`): the pilot's `InvocationExpression.parameter` +and `.input` list every parameter of the called calculation, a defaulted one the call does not bind +included, while this repository's writer lists the bound ones — so the 34 `WhereType` calls of +`examples` and `docs/manual/examples` moved from present-equal to present-different on both +properties (`InvocationExpression.input` 368 → 334 equal, `.parameter` 382 → 348) when the library +gained the parameter. The further pilot-library elements (198 for `exact`, 810 for `Differ`) are the +new declarations themselves. No other row moved. Listing the defaulted parameters is a writer follow-up. The scorecard is mostly **ours-missing**: this repository's writer emits a subset of the metamodel's properties, and the referee now measures which, per metaclass. The full ranked scorecard with one concrete example per differing row is `build/pilot-apijson-diff/pilot-apijson-diff.txt` after a run; the baseline keeps every row's seven counts. diff --git a/docs/project/pilot-differential-baseline.json b/docs/project/pilot-differential-baseline.json index d243166c3f..0977ca6b40 100644 --- a/docs/project/pilot-differential-baseline.json +++ b/docs/project/pilot-differential-baseline.json @@ -83,7 +83,7 @@ "dir": "internal/workspace/libs/stdlib/OpenSysML Libraries", "origin": "ours", "files": 16, - "digest": "sha256:29a3bf0fb7bb630a1457768ba774a6baf042469b071c8bda677117ed65d98c65" + "digest": "sha256:4f350c5a695cda953e0fb8f1b9b273c998f57480b9770bf063b6ca51a96cdf13" } ], "recorded": "2026-10-29" diff --git a/internal/doc/queryexec/differ.go b/internal/doc/queryexec/differ.go new file mode 100644 index 0000000000..db251c9169 --- /dev/null +++ b/internal/doc/queryexec/differ.go @@ -0,0 +1,194 @@ +package queryexec + +import ( + "fmt" + + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/suggest" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// differSide is one input of Differ with its key and column indexes and its rows by key. +type differSide struct { + name string + rows sequence + key int + column int + byKey map[keyBucket][]int +} + +// keyBucket is the comparable coarsening of a key value under which two values equal by +// valuesEqual share a bucket: rows by identity, numbers by value, quantities by base magnitude. +type keyBucket struct { + kind string + row setKey + position int + number float64 + text string + boolean bool +} + +func keyBucketOf(value Value) keyBucket { + if value.isRow() { + return keyBucket{kind: "row", row: setKeyOf(value)} + } + if connector, position, ok := value.ConnectorEnd(); ok { + return keyBucket{kind: "connectorEnd", row: setKey{row: rowKey{element: symbols.KeyOf(connector)}}, position: position} + } + if arithmeticKind(value.Kind()) { + return keyBucket{kind: "number", number: realOperand(value)} + } + if quantity, ok := value.Quantity(); ok { + return keyBucket{kind: "quantity", number: quantity.BaseMagnitude()} + } + if text, ok := value.String(); ok { + return keyBucket{kind: "string", text: text} + } + if boolean, ok := value.Boolean(); ok { + return keyBucket{kind: "boolean", boolean: boolean} + } + return keyBucket{kind: string(value.Kind())} +} + +// find is the row of the side whose key equals value under valuesEqual, if any. +func (s differSide) find(value Value) (int, bool) { + for _, row := range s.byKey[keyBucketOf(value)] { + if valuesEqual(s.cell(row, s.key)[0], value) { + return row, true + } + } + return 0, false +} + +func (s differSide) cell(row, column int) []Value { + if row < len(s.rows.cells) && column < len(s.rows.cells[row]) { + return s.rows.cells[row][column].values + } + return nil +} + +// evaluateDiffer is the keyed value comparison of two projected row sets: one row +// (key, left column, right column) per key on both sides whose column cells differ. +// A key on one side only is Except's; a key carried twice on one side is a model error. +func (e *executor) evaluateDiffer(expression queryplan.Expression) (sequence, error) { + left, err := e.rowArgument(expression, "left") + if err != nil { + return sequence{}, err + } + right, err := e.rowArgument(expression, "right") + if err != nil { + return sequence{}, err + } + key, err := e.stringArgument(expression, "key") + if err != nil { + return sequence{}, err + } + column, err := e.stringArgument(expression, "column") + if err != nil { + return sequence{}, err + } + if key == "left" || key == "right" { + // The result names its side columns left and right; a key so named would be ambiguous. + return sequence{}, e.invalidArgument(expression, "key", key) + } + step := Step{Kind: StepDiffer, Argument: key + ", " + column} + sides := [2]differSide{{name: "left", rows: left}, {name: "right", rows: right}} + var findings []Finding + for i := range sides { + side := &sides[i] + for _, name := range []string{key, column} { + if projectedColumn(side.rows, name) < 0 { + return sequence{}, e.unknownColumn(expression, step, side.name, name, side.rows.columns) + } + } + side.key = projectedColumn(side.rows, key) + side.column = projectedColumn(side.rows, column) + side.byKey = make(map[keyBucket][]int, len(side.rows.values)) + for row := range side.rows.values { + cell := side.cell(row, side.key) + if len(cell) != 1 { + findings = append(findings, Finding{ + Step: step, Element: rowTarget(side.rows.values[row]), Constraint: ConstraintMultiplicity, + Observed: fmt.Sprintf("%s key %s holds %d values", side.name, key, len(cell)), + }) + continue + } + if previous, duplicate := side.find(cell[0]); duplicate { + findings = append(findings, Finding{ + Step: step, Element: rowTarget(side.rows.values[row]), Constraint: ConstraintDuplicateKey, + Observed: fmt.Sprintf("%s key %s = %s is also carried by %s", side.name, key, e.valueText(cell[0]), rowTarget(side.rows.values[previous])), + }) + continue + } + bucket := keyBucketOf(cell[0]) + side.byKey[bucket] = append(side.byKey[bucket], row) + } + } + if len(findings) > 0 { + return sequence{}, &Error{ + Kind: ErrorDuplicateKey, + Query: e.definition.Name(), + Operation: expression.Operation(), + Property: key, + Actual: findings[0].Observed, + Origin: expression.Origin(), + Outcome: &Outcome{Model: &ModelError{Findings: findings}}, + } + } + origin := expression.Origin() + result := sequence{columns: []Column{{name: key, origin: origin}, {name: "left", origin: origin}, {name: "right", origin: origin}}} + for row := range left.values { + cell := sides[0].cell(row, sides[0].key) + other, both := sides[1].find(cell[0]) + if !both { + continue + } + leftValues, rightValues := sides[0].cell(row, sides[0].column), sides[1].cell(other, sides[1].column) + if cellsEqual(leftValues, rightValues) { + continue + } + // appendSelected keeps the left row's Tree depth; its cells are then the three of Differ. + appendSelected(&result, left, row) + result.cells[len(result.values)-1] = []Cell{ + {values: append([]Value(nil), cell...), origin: left.cells[row][sides[0].key].origin}, + {values: append([]Value(nil), leftValues...), origin: left.cells[row][sides[0].column].origin}, + {values: append([]Value(nil), rightValues...), origin: right.cells[other][sides[1].column].origin}, + } + } + return result, nil +} + +// cellsEqual is sequence equality of two cells under KerML ==. +func cellsEqual(left, right []Value) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if !valuesEqual(left[i], right[i]) { + return false + } + } + return true +} + +// unknownColumn is Differ's path error: one side projects no column of the name. +func (e *executor) unknownColumn(expression queryplan.Expression, step Step, side, name string, columns []Column) error { + names := make([]string, len(columns)) + for i, column := range columns { + names[i] = column.name + } + return &Error{ + Kind: ErrorUnknownColumn, + Query: e.definition.Name(), + Operation: expression.Operation(), + Parameter: side, + Property: name, + Candidates: names, + Origin: expression.Origin(), + Outcome: &Outcome{Path: &PathError{Failures: []Failure{{ + Step: step, + Expected: "a column named " + name + " on the " + side + " rows", + Frontier: Frontier{At: side, Nearest: first(suggest.Nearest(name, names))}, + }}}}, + } +} diff --git a/internal/doc/queryexec/differ_test.go b/internal/doc/queryexec/differ_test.go new file mode 100644 index 0000000000..c71700ec43 --- /dev/null +++ b/internal/doc/queryexec/differ_test.go @@ -0,0 +1,242 @@ +package queryexec + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +const differModel = ` +package Before { + part a { attribute mass = 1; } + part b { attribute mass = 2; } + part c { attribute mass = 3; } +} +package After { + part a { attribute mass = 1; } + part b { attribute mass = 5; } + part d { attribute mass = 4; } +} +package Twice { + part x { attribute mass = 1; } + part y { attribute mass = 1; } +} +package NestedBefore { + part outer { attribute mass = 1; part inner { attribute mass = 2; } } +} +package NestedAfter { + part outer { attribute mass = 5; part inner { attribute mass = 3; } } +} +package Once { + part x { attribute mass = 1; } + part z { attribute mass = 2.5; } +} +package RealKeys { + part p { attribute mass = 1.0; } + part q { attribute mass = 2.50; } +} +calc def PartMasses :> Query { + in root : Element; + Project(source = WhereType(source = OwnedElements(source = root), type = "PartUsage"), properties = ("name", "mass")) +} +calc def PartNames :> Query { + in root : Element; + Project(source = WhereType(source = OwnedElements(source = root), type = "PartUsage"), properties = ("name")) +} +calc def ChangedMasses :> Query { + in left : Element; + in right : Element; + Differ(left = PartMasses(root = left), right = PartMasses(root = right), key = "name", column = "mass") +} +calc def ChangedNamesByMass :> Query { + in left : Element; + in right : Element; + Differ(left = PartMasses(root = left), right = PartMasses(root = right), key = "mass", column = "name") +} +calc def ReservedKey :> Query { + in left : Element; + in right : Element; + Differ(left = PartMasses(root = left), right = PartMasses(root = right), key = "left", column = "mass") +} +calc def NestedMasses :> Query { + in root : Element; + Project(source = Tree(source = WhereType(source = Descendants(source = root), type = "PartUsage")), properties = ("name", "mass")) +} +calc def ChangedNestedMasses :> Query { + in left : Element; + in right : Element; + Differ(left = NestedMasses(root = left), right = NestedMasses(root = right), key = "name", column = "mass") +} +calc def MassesAgainstNames :> Query { + in left : Element; + in right : Element; + Differ(left = PartMasses(root = left), right = PartNames(root = right), key = "name", column = "mass") +} +` + +// TestExecuteDifferReportsChangedCellsByKey: Differ emits one (key, left, right) row per +// key on both sides whose column differs; keys on one side only are not rows. +func TestExecuteDifferReportsChangedCellsByKey(t *testing.T) { + fixture := loadExecutionFixture(t, differModel) + bindings := Bindings{ + "left": {ElementValue(fixture.symbol(t, "Before"))}, + "right": {ElementValue(fixture.symbol(t, "After"))}, + } + result, err := fixture.execute(t, "ChangedMasses", bindings, Options{}) + if err != nil { + t.Fatalf("ChangedMasses: %v", err) + } + var columns []string + for _, column := range result.Columns() { + columns = append(columns, column.Name()) + } + if got := strings.Join(columns, " "); got != "name left right" { + t.Fatalf("columns = %q, want the key then each side's cell", got) + } + rows := result.Rows() + if len(rows) != 1 { + t.Fatalf("ChangedMasses returned %d rows, want the one part whose mass changed", len(rows)) + } + sym, _ := rows[0].Element().Element() + if sym.Name != "b" { + t.Fatalf("row names %s, want b", sym.Name) + } + var cells []string + for _, cell := range rows[0].Cells() { + var texts []string + for _, value := range cell.Values() { + texts = append(texts, differValueText(t, value)) + } + cells = append(cells, strings.Join(texts, ",")) + } + if got := strings.Join(cells, " | "); got != "b | 2 | 5" { + t.Fatalf("cells = %q, want the key and both masses", got) + } + + same, err := fixture.execute(t, "ChangedMasses", Bindings{ + "left": {ElementValue(fixture.symbol(t, "Before"))}, + "right": {ElementValue(fixture.symbol(t, "Before"))}, + }, Options{}) + if err != nil { + t.Fatalf("ChangedMasses against itself: %v", err) + } + if n := len(same.Rows()); n != 0 { + t.Fatalf("a table against itself differs in %d rows, want none", n) + } +} + +// TestExecuteDifferMatchesKeysUnderValueEquality: keys match as valuesEqual does — an +// integer 1 and a real 1.0 are one key — not by their spelling. +func TestExecuteDifferMatchesKeysUnderValueEquality(t *testing.T) { + fixture := loadExecutionFixture(t, differModel) + result, err := fixture.execute(t, "ChangedNamesByMass", Bindings{ + "left": {ElementValue(fixture.symbol(t, "Once"))}, + "right": {ElementValue(fixture.symbol(t, "RealKeys"))}, + }, Options{}) + if err != nil { + t.Fatalf("ChangedNamesByMass over Once and RealKeys: %v", err) + } + var rows []string + for _, row := range result.Rows() { + sym, _ := row.Element().Element() + cells := row.Cells() + left, _ := cells[1].Values()[0].String() + right, _ := cells[2].Values()[0].String() + rows = append(rows, sym.Name+":"+left+"->"+right) + } + if got := strings.Join(rows, " "); got != "x:x->p z:z->q" { + t.Fatalf("rows = %q, want both masses matched across integer and real spellings", got) + } +} + +// TestExecuteDifferKeepsTreeDepth: a changed row of a Tree-arranged left side keeps its depth. +func TestExecuteDifferKeepsTreeDepth(t *testing.T) { + fixture := loadExecutionFixture(t, differModel) + result, err := fixture.execute(t, "ChangedNestedMasses", Bindings{ + "left": {ElementValue(fixture.symbol(t, "NestedBefore"))}, + "right": {ElementValue(fixture.symbol(t, "NestedAfter"))}, + }, Options{}) + if err != nil { + t.Fatalf("ChangedNestedMasses: %v", err) + } + var rows []string + for _, row := range result.Rows() { + sym, _ := row.Element().Element() + rows = append(rows, fmt.Sprintf("%s@%d", sym.Name, row.Depth())) + } + if got := strings.Join(rows, " "); got != "outer@0 inner@1" { + t.Fatalf("rows = %q, want the outer part at depth 0 and the inner nested under it", got) + } +} + +// TestExecuteDifferReservedKeyIsInvalid: a key named left or right would collide with the +// result's side columns, so it is a typed invalid argument. +func TestExecuteDifferReservedKeyIsInvalid(t *testing.T) { + fixture := loadExecutionFixture(t, differModel) + _, err := fixture.execute(t, "ReservedKey", Bindings{ + "left": {ElementValue(fixture.symbol(t, "Before"))}, + "right": {ElementValue(fixture.symbol(t, "After"))}, + }, Options{}) + var failure *Error + if !errors.As(err, &failure) || failure.Kind != ErrorInvalidArgument || failure.Parameter != "key" { + t.Fatalf("ReservedKey: %v, want %s on key", err, ErrorInvalidArgument) + } +} + +// TestExecuteDifferDuplicateKeyIsAModelError: a key value on two rows of one side is the +// duplicate-key model error naming every offending row, not a silent choice. +func TestExecuteDifferDuplicateKeyIsAModelError(t *testing.T) { + fixture := loadExecutionFixture(t, differModel) + _, err := fixture.execute(t, "ChangedNamesByMass", Bindings{ + "left": {ElementValue(fixture.symbol(t, "Twice"))}, + "right": {ElementValue(fixture.symbol(t, "After"))}, + }, Options{}) + var failure *Error + if !errors.As(err, &failure) || failure.Kind != ErrorDuplicateKey { + t.Fatalf("ChangedNamesByMass over Twice: %v, want %s", err, ErrorDuplicateKey) + } + if failure.Outcome == nil || failure.Outcome.Model == nil || len(failure.Outcome.Model.Findings) != 1 { + t.Fatalf("outcome = %+v, want one duplicateKey finding", failure.Outcome) + } + finding := failure.Outcome.Model.Findings[0] + if finding.Constraint != ConstraintDuplicateKey || finding.Element != "Observatory::Twice::y" || finding.Step.Label() != "Differ(mass, name)" { + t.Fatalf("finding = %+v, want duplicateKey on Observatory::Twice::y at Differ(mass, name)", finding) + } + if !strings.Contains(finding.Observed, "Observatory::Twice::x") { + t.Fatalf("observed %q does not name the other carrier Twice::x", finding.Observed) + } +} + +// TestExecuteDifferUnknownColumnIsAPathError: a side that projects no such column is the +// unknown-column path error at the Differ step, naming the side and its columns. +func TestExecuteDifferUnknownColumnIsAPathError(t *testing.T) { + fixture := loadExecutionFixture(t, differModel) + _, err := fixture.execute(t, "MassesAgainstNames", Bindings{ + "left": {ElementValue(fixture.symbol(t, "Before"))}, + "right": {ElementValue(fixture.symbol(t, "After"))}, + }, Options{}) + var failure *Error + if !errors.As(err, &failure) || failure.Kind != ErrorUnknownColumn { + t.Fatalf("MassesAgainstNames: %v, want %s", err, ErrorUnknownColumn) + } + if failure.Parameter != "right" || failure.Property != "mass" || strings.Join(failure.Candidates, ",") != "name" { + t.Fatalf("error = %+v, want the right side lacking mass among name", failure) + } + if failure.Outcome == nil || failure.Outcome.Path == nil || failure.Outcome.Step().Label() != "Differ(name, mass)" { + t.Fatalf("outcome = %+v, want a path error at Differ(name, mass)", failure.Outcome) + } +} + +// differValueText spells a string or integer cell value for a test expectation. +func differValueText(t *testing.T, value Value) string { + t.Helper() + if text, ok := value.String(); ok { + return text + } + if integer, ok := value.IntegerConst(); ok { + return integer.FormatInt() + } + t.Fatalf("unexpected cell value of kind %s", value.Kind()) + return "" +} diff --git a/internal/doc/queryexec/errors.go b/internal/doc/queryexec/errors.go index 0fe9f1fc38..c1c81da96c 100644 --- a/internal/doc/queryexec/errors.go +++ b/internal/doc/queryexec/errors.go @@ -49,7 +49,12 @@ const ( ErrorColumnIncommensurable ErrorKind = "column-incommensurable" ErrorColumnArithmetic ErrorKind = "column-arithmetic" ErrorColumnAbsent ErrorKind = "column-absent" - ErrorColumnCardinality ErrorKind = "column-cardinality" + // ErrorUnknownColumn: Differ named a key or column one side does not project. + ErrorUnknownColumn ErrorKind = "unknown-column" + // ErrorDuplicateKey: a Differ side carries one key value on two rows, or a key cell + // that is not single-valued — the model error, every finding kept. + ErrorDuplicateKey ErrorKind = "duplicate-key" + ErrorColumnCardinality ErrorKind = "column-cardinality" // ErrorNoRuntime: an operation over a session's objects ran with no session. ErrorNoRuntime ErrorKind = "no-runtime" // ErrorObjectRow: a model-only operation was given a runtime object row. @@ -234,6 +239,10 @@ func (e *Error) Error() string { return fmt.Sprintf("query %s references unknown classification %s", e.Query, e.Actual) case ErrorMissingFeature: return e.missingFeatureMessage() + case ErrorUnknownColumn: + return fmt.Sprintf("query %s operation %s: the %s rows project no column %s (columns: %s)", e.Query, e.Operation, e.Parameter, e.Property, strings.Join(e.Candidates, ", ")) + case ErrorDuplicateKey: + return fmt.Sprintf("query %s operation %s: %s", e.Query, e.Operation, e.Actual) case ErrorUnknownElement: return e.unknownElementMessage() case ErrorAmbiguousElement: diff --git a/internal/doc/queryexec/execute.go b/internal/doc/queryexec/execute.go index df06a5f993..840736ab27 100644 --- a/internal/doc/queryexec/execute.go +++ b/internal/doc/queryexec/execute.go @@ -439,6 +439,8 @@ func (e *executor) evaluate(expression queryplan.Expression) (sequence, error) { return e.evaluateExcept(expression) case queryplan.OperationUnion: return e.evaluateUnion(expression) + case queryplan.OperationDiffer: + return e.evaluateDiffer(expression) case queryplan.OperationStates: return e.evaluateStates(expression) case queryplan.OperationInState: diff --git a/internal/doc/queryexec/outcome.go b/internal/doc/queryexec/outcome.go index c32b89bb94..33459acf46 100644 --- a/internal/doc/queryexec/outcome.go +++ b/internal/doc/queryexec/outcome.go @@ -20,6 +20,7 @@ const ( StepRelated StepKind = "Related" StepWhereFeature StepKind = "WhereFeature" StepProject StepKind = "Project" + StepDiffer StepKind = "Differ" ) // Step is one operation of the query with its argument: the name Named looked @@ -115,6 +116,7 @@ const ( ConstraintMultiplicity Constraint = "multiplicity" ConstraintTyping Constraint = "typing" ConstraintUnit Constraint = "unit" + ConstraintDuplicateKey Constraint = "duplicateKey" ) // Finding is one finding about Element at Step: a constraint the model violates. diff --git a/internal/frontend/repl/unquoted_name_test.go b/internal/frontend/repl/unquoted_name_test.go index 682492cf67..dac047f461 100644 --- a/internal/frontend/repl/unquoted_name_test.go +++ b/internal/frontend/repl/unquoted_name_test.go @@ -121,13 +121,13 @@ func TestNoQuotingHintWithoutAQuotedDeclaration(t *testing.T) { } // A name holding `::` is offered quoted whole, so the hint can be typed back: -// `T::'left::right-X'`, not a path through a `left` that is declared nowhere. +// `T::'fore::aft-X'`, not a path through a `fore` that is declared nowhere. func TestUnquotedNameWithAnEmbeddedSeparatorIsQuotedWhole(t *testing.T) { - s := submitted(t, `package T { part def 'left::right-X'; }`) + s := submitted(t, `package T { part def 'fore::aft-X'; }`) const rule = "Names containing ':' or '-' must be quoted." - wants(t, run(t, s, "%instantiate T::left"), - "error: unresolved reference: T::left — did you mean T::'left::right-X'? "+rule) - wants(t, run(t, s, "%instantiate left"), - "error: unresolved reference: left — did you mean T::'left::right-X'? "+rule) - wants(t, run(t, s, "%instantiate T::'left::right-X'"), "✓ Created instance") + wants(t, run(t, s, "%instantiate T::fore"), + "error: unresolved reference: T::fore — did you mean T::'fore::aft-X'? "+rule) + wants(t, run(t, s, "%instantiate fore"), + "error: unresolved reference: fore — did you mean T::'fore::aft-X'? "+rule) + wants(t, run(t, s, "%instantiate T::'fore::aft-X'"), "✓ Created instance") } diff --git a/internal/ir/queryplan/compiler.go b/internal/ir/queryplan/compiler.go index 68dd1af122..9620b5deef 100644 --- a/internal/ir/queryplan/compiler.go +++ b/internal/ir/queryplan/compiler.go @@ -43,6 +43,7 @@ var builtins = map[string]builtin{ "DocumentQueries::WhereRelated": {OperationWhereRelated}, "DocumentQueries::Except": {OperationExcept}, "DocumentQueries::Union": {OperationUnion}, + "DocumentQueries::Differ": {OperationDiffer}, "DocumentQueries::Tree": {OperationTree}, } diff --git a/internal/ir/queryplan/plan.go b/internal/ir/queryplan/plan.go index 67e743e790..0bc8789164 100644 --- a/internal/ir/queryplan/plan.go +++ b/internal/ir/queryplan/plan.go @@ -53,6 +53,8 @@ const ( // OperationExcept and OperationUnion are the ordered set operations over rows. OperationExcept Operation = "except" OperationUnion Operation = "union" + // OperationDiffer is the keyed value comparison of two projected row sets. + OperationDiffer Operation = "differ" // OperationTree arranges rows as a containment tree, each with its depth. OperationTree Operation = "tree" // OperationVariable reads a cell's row parameter or the element a diff --git a/internal/workspace/libs/extensions-project/.meta.json b/internal/workspace/libs/extensions-project/.meta.json index 026058eb36..601652ecae 100644 --- a/internal/workspace/libs/extensions-project/.meta.json +++ b/internal/workspace/libs/extensions-project/.meta.json @@ -14,7 +14,7 @@ }, "libraries/DocumentQueries.sysml": { "algorithm": "SHA256", - "value": "7cad8b83772182db5aa917ed36c19feb2e0796ecd6cfbc3b6b1a4c33729b11a7" + "value": "c8f30ccffcc2c98609498c7cef83bbd5fdf74423230591721650bb4d1737f5b0" }, "libraries/IdentityMetadata.sysml": { "algorithm": "SHA256", @@ -69,7 +69,7 @@ "value": "9e00f049209584092dc8bcb398b736c08e6a39b1db667a8a744de3df72a543bf" } }, - "created": "2026-10-29T15:08:48Z", + "created": "2026-10-29T15:36:02Z", "index": { "AnalysisRecords": "libraries/AnalysisRecords.sysml", "DiagramLayout": "libraries/DiagramLayout.sysml", diff --git a/internal/workspace/libs/stdlib.snapshot b/internal/workspace/libs/stdlib.snapshot index 0eb658997d..868bcaa306 100644 Binary files a/internal/workspace/libs/stdlib.snapshot and b/internal/workspace/libs/stdlib.snapshot differ diff --git a/internal/workspace/libs/stdlib/OpenSysML Libraries/DocumentQueries.sysml b/internal/workspace/libs/stdlib/OpenSysML Libraries/DocumentQueries.sysml index bcdd3f3272..16c956b8c2 100644 --- a/internal/workspace/libs/stdlib/OpenSysML Libraries/DocumentQueries.sysml +++ b/internal/workspace/libs/stdlib/OpenSysML Libraries/DocumentQueries.sysml @@ -423,6 +423,18 @@ library package DocumentQueries { return result : Element[0..*] ordered; } + /* Differ: one row (key, column of left, column of right) per key value present + * in both left and right whose column cells differ — a keyed value comparison; + * keys on one side only are Except's. A key value carried by two rows on one + * side is a model error. */ + calc def Differ { + in left : Element[0..*] ordered; + in right : Element[0..*] ordered; + in key : String[1]; + in column : String[1]; + return result : Element[0..*] ordered; + } + /* Tree: the source rows arranged as a containment tree in pre-order, each * row nested under the nearest row containing it — its nearest owner among * the rows, or the individual whose part it is — at the depth the table diff --git a/internal/workspace/libs/stdlib/OpenSysML Libraries/engine-contract.json b/internal/workspace/libs/stdlib/OpenSysML Libraries/engine-contract.json index 27de97305c..6ce25aefa2 100644 --- a/internal/workspace/libs/stdlib/OpenSysML Libraries/engine-contract.json +++ b/internal/workspace/libs/stdlib/OpenSysML Libraries/engine-contract.json @@ -248,6 +248,13 @@ "internal/ir/docplan/compiler.go" ] }, + { + "name": "DocumentQueries::Differ", + "kind": "document", + "consumers": [ + "internal/ir/queryplan/compiler.go" + ] + }, { "name": "DocumentQueries::Distinct", "kind": "document", diff --git a/scripts/extension-libraries-pin.sh b/scripts/extension-libraries-pin.sh index 2dc047faf8..75a59f1fda 100755 --- a/scripts/extension-libraries-pin.sh +++ b/scripts/extension-libraries-pin.sh @@ -8,4 +8,4 @@ # verifies, because a ref is mutable. Change them together. EXTENSIONS_REPO="${EXTENSIONS_REPO:-https://github.com/Open-MBEE/OpenSysML-Extensions-Library.git}" EXTENSIONS_REF="${EXTENSIONS_REF:-main}" -EXTENSIONS_COMMIT="${EXTENSIONS_COMMIT:-1ef079deaa98ff8be988ab7dea2f7a8c0640fb22}" +EXTENSIONS_COMMIT="${EXTENSIONS_COMMIT:-b87334ffd1bbc7461806e8a0858823be6f8335c1}" diff --git a/scripts/sync-extension-libraries.sh b/scripts/sync-extension-libraries.sh index d97ff89d5c..2387d5afe8 100755 --- a/scripts/sync-extension-libraries.sh +++ b/scripts/sync-extension-libraries.sh @@ -197,3 +197,11 @@ mkdir -p "$vendored_project" for name in "${removed[@]}"; do rm -f "$name"; done for name in "${added[@]}" "${changed[@]}"; do cp "$(source_of "$name")" "$name"; done echo "Synced extension libraries to $resolved" +cat <<'EOT' +The vendored libraries changed. Before opening the pull request, in this order: + go generate ./internal/workspace/libs # stdlib.snapshot (static job: stdlib-snapshot-check) + make extension-libraries-pin-check # engine contract, baseline provenance, stdlib and query tests +A failing provenance guard means the pilot-differential baselines record the old library digest: +adjudicate with `go run -C tools ./cmd/pilot-diff` / `./cmd/pilot-apijson-diff` and re-record with -update +(see AGENTS.md, "Bumping the extension-library pin"). +EOT