Skip to content

v0.46.0 — Structured Expression Capture (Capture-Don't-Evaluate)

Status: - Phase 0 (prerequisite) — DONE in v0.48.0: assert constraint / constraint / calc / state / action / requirement / satisfy / allocation usages inside part def, item def, etc. bodies now survive Part.load_from_grammar into the public-API tree. 10 regression tests in tests/grammar_test.py::*v046_phase0*. - Phase 1 (expression refactor) — DONE in v0.52.0: visitor now emits a structured expression dict with operators captured at the correct layer, using a precedence-climbing pass to recover the correct operator order from the left-associative ANTLR parse. 10 regression tests in tests/grammar_test.py::*v046_phase1 (binary equality, invocation, feature chain, arithmetic precedence, unary minus, not operator, logical and/or (text-preservation fallback), conditional ternary (layer-presence), range, structural non-collapse). Known limitations: - and/or/xor/implies fall back to text preservation (the grammar classes use list-based operand/operator fields that don't fit the chain layout). - a.b field access falls back to text preservation (the grammar class lacks a FeatureChainExpression that BaseExpression can read). - The grammar's IF ? : ternary form is not currently parseable inside calc bodies (known grammar limitation). Author: Context from issue #3 triage + codebase investigation. Target release: v0.48.0 (Phase 0), v0.52.0 (Phase 1) Estimated effort: ~1 week (see §8).


1. Problem

Today, _visit_owned_expression (src/sysmlpy/antlr_visitor.py:10557) collapses every non-literal expression into a single FeatureReferenceMember whose memberElement is a QualifiedName with the whole expression text as a single name part. Example:

assert constraint { radius == zero }
# visitor emits:
#   FeatureReferenceExpression
#     FeatureReferenceMember
#       QualifiedName { names: ["radius==zero"] }   # ← entire text, structure lost

The wrapping EqualityExpression / AdditiveExpression / … chain in _wrap_expression_layers is emitted with empty operator/operand arrays for every binary layer, so the operator is never captured. Round-trip works only because QualifiedName.dump() returns the raw text verbatim.

This makes a future name resolver infeasible: a resolver needs individually addressable FeatureReferenceExpression nodes (one per identifier) so it can look each up in the symbol table. With the current lossy collapse, the resolver would have to re-parse raw text — duplicating grammar work and drifting from the ANTLR grammar.

A second, related bug blocks testing: assert constraint inside a part def body is dropped by Part.load_from_grammar in src/sysmlpy/usage.py:782. That dispatch handles PartDefinition / ItemUsage / AttributeUsage / etc. but has no branch for AssertConstraintUsage / ConstraintUsage / CalculationUsage. The visitor emits the assert constraint correctly; the grammar classes construct it correctly; usage.py discards it when building the public-API Part tree. So model.dump() silently omits every assert constraint inside a part. This must be fixed first so expression-capture tests can even observe the round-trip.

2. Goal

Replace the lossy single-FeatureReferenceMember collapse with a structured expression dict that preserves operator structure, so a future v0.47+ name resolver can walk identifiers individually.

In scope: - Prerequisite: plumb AssertConstraintUsage / ConstraintUsage / CalculationUsage / StateUsage / ActionUsage through Part.load_from_grammar so they survive into the public-API Part.children. - Refactor _visit_owned_expression to walk the ANTLR ownedExpression subtree and emit per-rule dicts keyed on the expression kind (BinaryExpression, InvocationExpression, FeatureChainExpression, LiteralInteger, LiteralReal, LiteralString, LiteralBoolean, FeatureReferenceExpression, MetadataAccessExpression, ConstructorExpression, CastExpression, ConditionalExpression, UnaryExpression, ParenthesizedExpression, IfExpression). - Add matching grammar classes in grammar/classes.py for any new dict shapes that don't already have a class (most already exist — see §4). - Public API: Model.find_expressions() helper that walks assertConstraintUsage / calculationUsage / requirementUsage and yields (constraint, expression) pairs. - Documentation: README + STATUS + CHANGELOG.

Out of scope (deferred to v0.47+): - Name resolution (looking up identifiers in the symbol table). - Type checking (verifying operand types are compatible). - OCL evaluation (evaluating the expression to a value). - Constraint-body semantic checks (the "check" half of "parse + check").

3. Architecture: the capture layer

SysML text
  → ANTLR4 parse tree (ownedExpression context tree)
  → _visit_owned_expression (REFACTORED)
  → structured OwnedExpression dict
  → grammar/classes.py (OwnedExpression / ConditionalExpression / …)
  → dump() / get_definition() round-trip
  → (future v0.47+) semantic.py walks the structured dict, resolves names

The refactor touches only the visitor emission and the grammar-class construction. Everything downstream (the OwnedExpression / ConditionalExpression / EqualityExpression / … class hierarchy in grammar/classes.py:5539-6350) already exists and already round-trips — the problem is that the visitor never fills the operator/operand arrays, so the classes get empty data.

4. Existing grammar classes (most already exist)

grammar/classes.py already has the full expression class hierarchy (built when the visitor emits the empty-chain shape from _wrap_expression_layers):

Class Line Status
OwnedExpression 5539 exists, works
ConditionalExpression 5554 exists, but dump() just joins operands — won't render ? : until visitor fills operators
NullCoalescingExpression 5572 exists
ImpliesExpression 5599 exists
OrExpression 5626 exists
XorExpression 5653 exists
AndExpression 5727 exists
EqualityExpression 5773 exists — needs operator rendering in dump()
ClassificationExpression 5796 exists
RelationalExpression 5841 exists — needs operator rendering
RangeExpression 5865 exists
AdditiveExpression 5910 exists — needs operator rendering
MultiplicativeExpression 5955 exists — needs operator rendering
ExponentiationExpression 5975 exists
UnaryExpression 6002 exists
ExtentExpression 6029 exists
PrimaryExpression 6067 exists, fully working (handles [], (), ., ->, #)
BaseExpression 6250 exists, dispatches FeatureReferenceExpression / LiteralInteger / LiteralReal / LiteralString / SequenceExpression / InvocationExpression
FeatureReferenceExpression 6399 exists
FeatureReferenceMember 6419 exists
SequenceExpression 6224 exists
BodyExpression 6176-ish exists
InvocationExpression ~6310 exists
ArgumentList / PositionalArgumentList / NamedArgumentList / ArgumentMember / Argument ~6330+ exist
LiteralInteger / LiteralReal / LiteralString 8107-8145 exist
LiteralInfinity ~8145 exists
LiteralBoolean grep for it may need adding
FeatureChainMember 6176 exists

Net new classes likely needed: LiteralBoolean (if absent), and possibly CastExpression / ConstructorExpression / MetadataAccessExpression / IfExpression if the current PrimaryExpression / BaseExpression dispatch doesn't already cover them. Audit during implementation; do not add classes that already exist.

Net new behavior needed: the dump() methods on EqualityExpression / RelationalExpression / AdditiveExpression / MultiplicativeExpression / ExponentiationExpression / UnaryExpression / ConditionalExpression must render the operator between operands. Today they just join operands (no operator text), because the visitor never fills the operator arrays. After the refactor, the visitor fills the operators, and dump() must emit lhs OP rhs instead of lhsrhs.

5. Implementation plan

Phase 0 — Prerequisite: assert constraint survives Part.load_from_grammar

File: src/sysmlpy/usage.py (Usage.load_from_grammar, line 782).

The children-dispatch loop (lines ~920-1078) handles PartDefinition, ItemDefinition, PartUsage, ItemUsage, AttributeUsage, StructureUsageElement, StructureDefinitionElement, Definition. It does NOT handle AssertConstraintUsage, ConstraintUsage, CalculationUsage, StateUsage, ActionUsage, RequirementUsage, SatisfyRequirementUsage, FlowConnectionUsage (as children of a part), ConnectionUsage (as child of part), InterfaceUsage, etc.

Fix: add a branch for each missing usage kind. For AssertConstraintUsage / ConstraintUsage / CalculationUsage, construct a Constraint (or Calculation) public-API object, set .grammar = inner_element, extract name from the declaration if present, set .parent = self, append to self.children. Mirror the pattern already used at the package level in definition.py:1269-1290.

Risk: Part.dump() and Attribute.usage_dump() rebuild the body via _get_definition(child="DefinitionBody") — need to verify the rebuilt dict includes the assert constraint. If Constraint doesn't have a _get_definition that emits the right DefinitionBodyItem shape, the round-trip will drop it. Check Constraint._get_definition (in usage.py) and extend if needed.

Tests (failing-first, commit before the visitor refactor): - test_assert_constraint_survives_part_def_load_gh3_prereq: part def W { attribute r; assert constraint { r == 0 } } → model.dump() contains assert constraint. - test_constraint_usage_survives_part_def_load_gh3_prereq: part def W { constraint c { r == 0 } } → model.dump() contains constraint c. - test_calculation_usage_survives_part_def_load_gh3_prereq: part def W { calc mass { sum(parts.mass) } } → model.dump() contains calc mass.

Phase 1 — Visitor refactor: structured expression emission

File: src/sysmlpy/antlr_visitor.py.

Replace _visit_owned_expression (line 10557) with a dispatcher that walks the ANTLR ownedExpression context tree and fills the existing expression chain with real operator/operand data instead of empty arrays.

Key insight: the ANTLR ownedExpression rule is left-recursive with operator precedence (see SysMLv2Parser.g4:22-62). ANTLR parses radius == zero as:

ownedExpression
  : ownedExpression EQ_EQ ownedExpression    # the == alternative
      ↳ lhs: ownedExpression (baseExpression)
      ↳ rhs: ownedExpression (baseExpression)

ANTLR's Python runtime exposes this as: oe_ctx.getChildCount() == 3, oe_ctx.getChild(0) is the lhs ownedExpression, oe_ctx.getChild(1) is the EQ_EQ terminal, oe_ctx.getChild(2) is the rhs ownedExpression. For unary prefix (-x, not x): getChildCount() == 2, getChild(0) is the operator terminal, getChild(1) is the operand. For the base case (baseExpression): oe_ctx.baseExpression() is non-null.

New helper functions (add near _visit_owned_expression):

def _emit_expression(oe_ctx):
    """Walk an ownedExpression context and return a structured OwnedExpression
    dict. Dispatches on ANTLR child shape: binary operator, unary operator,
    conditional (if/then/else), or base expression."""
    ...

def _emit_binary_expression(lhs_ctx, op_terminal, rhs_ctx):
    """Fill the EqualityExpression / RelationalExpression / AdditiveExpression /
    MultiplicativeExpression / ExponentiationExpression / RangeExpression /
    AndExpression / OrExpression / XorExpression / ImpliesExpression /
    NullCoalescingExpression / ConditionalExpression layers with real operator
    and operand data."""
    ...

def _emit_unary_expression(op_terminal, operand_ctx):
    """Fill the UnaryExpression layer with the operator and operand."""
    ...

def _emit_conditional_expression(if_ctx, then_ctx, else_ctx):
    """Fill the ConditionalExpression layer with condition/then/else."""
    ...

def _emit_base_expression(base_ctx):
    """Dispatch a baseExpression to the right primary: literal, feature
    reference, invocation, constructor, body expression, cast, or
    parenthesized expression."""
    ...

Strategy for _emit_binary_expression: the existing _wrap_expression_layers builds the empty chain. Reuse it as a skeleton, but instead of passing a single primary_expression at the bottom, walk the lhs and rhs recursively and splice them into the right operator layer. Map the operator terminal to the layer:

Operator terminal(s) Expression layer (existing class)
EQ_EQ, BANG_EQ, EQ_EQ_EQ, BANG_EQ_EQ EqualityExpression
LT, GT, LE, GE RelationalExpression
PLUS, MINUS AdditiveExpression
STAR, SLASH, PERCENT MultiplicativeExpression
STAR_STAR, CARET ExponentiationExpression
DOT_DOT RangeExpression
AMP AndExpression
PIPE OrExpression
XOR XorExpression
IMPLIES ImpliesExpression
QUESTION_QUESTION NullCoalescingExpression
IF / QUESTION / ELSE (ternary) ConditionalExpression

For each binary node, emit:

{
  "name": "<LayerName>Expression",       # e.g. "EqualityExpression"
  "operator": [op_text],                 # e.g. ["=="]
  "operand": [lhs_dict, rhs_dict],        # two operands, recursively structured
  # ...nested layers below this one, filled by recursing into lhs/rhs...
}

Critical: keep the existing nested-layer structure (the ConditionalExpression → NullCoalescingExpression → ImpliesExpression → … → PrimaryExpression chain) so existing grammar classes still work. The change is that the operator/operand arrays are now populated with real data instead of empty.

Backstop: if _emit_expression can't recognize a shape (defensive — should not happen for valid SysML), fall back to the existing _make_feature_reference_primary(text) path. Print a [expression] unhandled X warning (mirrors the AGENTS.md catch-all pattern). This preserves the graceful-fallback contract.

Phase 2 — Grammar class dump() fixes

File: src/sysmlpy/grammar/classes.py.

The existing dump() methods on EqualityExpression, RelationalExpression, AdditiveExpression, MultiplicativeExpression, ExponentiationExpression, UnaryExpression, ConditionalExpression currently join operands with no operator text (because the visitor never filled operators). After Phase 1, the visitor fills operators, so dump() must emit lhs OP rhs.

Pattern (apply to each binary-expression class):

def dump(self):
    parts = []
    for i, operand in enumerate(self.operands):
        parts.append(operand.dump())
        if i < len(self.operator):
            parts.append(self.operator[i])   # the operator string
    return " ".join(parts)   # or "" .join, matching existing whitespace

Audit each class's existing dump() and adjust. Verify against the failing-first tests from Phase 0 + new expression regression tests.

For UnaryExpression:

def dump(self):
    if self.operator:
        return f"{self.operator} {self.operand.dump()}"   # or no space, per grammar
    return self.operand.dump()

For ConditionalExpression (if/then/else):

def dump(self):
    # if cond ? then : else  →  SysML syntax is "if cond then x else y"
    # Verify the exact SysML v2 syntax for conditional expressions.

Risk: whitespace. Existing tests use strip_ws(text) == strip_ws(dump()), so whitespace differences are tolerated. But new tests should use exact whitespace where it matters for readability.

Phase 3 — Public API

File: src/sysmlpy/__init__.py (or a new src/sysmlpy/expressions.py module, exported from __init__.py).

Add:

def find_expressions(model) -> list[tuple[Any, Any]]:
    """Walk a model and yield (container, expression) pairs for every
    assert constraint, constraint usage, and calculation usage body.

    Each `expression` is the structured OwnedExpression dict (the visitor
    output). A future name resolver (v0.47+) can consume this directly.

    Returns
    -------
    list[tuple[Any, dict]]
        List of (container_element, expression_dict) pairs.
    """
    ...

Walk the model: for each Part / Attribute / Constraint / Calculation / Requirement in model.children (recursively), find AssertConstraintUsage / ConstraintUsage / CalculationUsage in .grammar.body (or .grammar.definition.body), drill into ResultExpressionMember.ownedRelatedElement, yield the pair.

Tests: - test_find_expressions_returns_structured_gh3: for a model with assert constraint { radius == zero }, find_expressions(model) returns a list whose first element's expression dict contains an EqualityExpression with operator: ["=="] and two FeatureReferenceExpression operands (radius, zero).

Phase 4 — Regression tests

File: tests/grammar_test.py (add at the end, near the issue #3 tests).

Add these failing-first tests, commit them BEFORE the refactor so they document the expected end state:

def test_expression_capture_binary_regression_gh3_capture():
    """`radius == zero` must round-trip with the operator captured."""
    text = """package P {
        part def W {
            attribute r;
            assert constraint { r == zero }
        }
    }"""
    a = loads(text)
    dumped = classtree(a).dump()
    assert "==" in dumped, f"binary operator lost; got: {dumped!r}"
    assert "r" in dumped and "zero" in dumped


def test_expression_capture_invocation_regression_gh3_capture():
    """`size(edges) == 18` must round-trip with the invocation captured."""
    text = """package P {
        part def Shape {
            attribute edges;
            assert constraint { size(edges) == 18 }
        }
    }"""
    a = loads(text)
    dumped = classtree(a).dump()
    assert "size(edges)" in dumped or "size ( edges )" in dumped
    assert "==" in dumped and "18" in dumped


def test_expression_capture_chain_regression_gh3_capture():
    """`wheel1.mass` must round-trip as a feature chain, not raw text."""
    text = """package P {
        part def Vehicle {
            part wheel1 { attribute mass; }
            assert constraint { wheel1.mass > 0 }
        }
    }"""
    a = loads(text)
    dumped = classtree(a).dump()
    assert "wheel1" in dumped and "mass" in dumped
    assert ">" in dumped and "0" in dumped


def test_expression_capture_arithmetic_regression_gh3_capture():
    """`a + b * c` must round-trip with precedence preserved."""
    text = """package P {
        part def E {
            attribute a; attribute b; attribute c;
            assert constraint { a + b * c == 0 }
        }
    }"""
    a = loads(text)
    dumped = classtree(a).dump()
    assert "+" in dumped and "*" in dumped and "==" in dumped


def test_expression_capture_unary_regression_gh3_capture():
    """`-x` and `not flag` must round-trip with the unary operator captured."""
    text = """package P {
        part def E {
            attribute x; attribute flag;
            assert constraint { -x == 0 and not flag }
        }
    }"""
    a = loads(text)
    dumped = classtree(a).dump()
    assert "-" in dumped and "not" in dumped

Also add a structural test (not just text round-trip) to lock in the dict shape so a future resolver has something to walk:

def test_expression_dict_has_equality_layer_gh3_capture():
    """The visitor must emit a populated EqualityExpression, not an empty one
    with the whole text collapsed into a FeatureReferenceMember."""
    from sysmlpy.antlr_parser import parse
    from sysmlpy.antlr_visitor import _visit_root_namespace_dict
    text = """package P {
        part def W {
            attribute r;
            assert constraint { r == zero }
        }
    }"""
    d = _visit_root_namespace_dict(parse(text))

    def find_first(node, target):
        if isinstance(node, dict):
            if node.get("name") == target:
                return node
            for v in node.values():
                r = find_first(v, target) if isinstance(v, (dict, list)) else None
                if r: return r
        elif isinstance(node, list):
            for it in node:
                r = find_first(it, target) if isinstance(it, (dict, list)) else None
                if r: return r
        return None

    eq = find_first(d, "EqualityExpression")
    assert eq is not None, "EqualityExpression layer missing entirely"
    assert len(eq.get("operator", [])) >= 1, \
        f"EqualityExpression has no operator; got: {eq!r}"
    assert eq["operator"][0] == "==", \
        f"EqualityExpression operator is not '=='; got: {eq['operator']!r}"

Phase 5 — Documentation

  • README.md: update the scope reminder (added in v0.45.0) — note that expressions are now captured structurally even if names aren't yet resolved. Remove the strongest form of "parse ≠ check"; replace with "names inside expressions are now captured as structured trees; resolution is future work".
  • STATUS.md: move the semantic.py constraint-body entry from "Known Issues" to "Remaining Work" with the v0.46.0 intermediate milestone noted. Add a new "Known Issues" entry: "Expression operators are captured but dump() whitespace for binary operators may differ from the source (whitespace-normalized round-trip still passes)".
  • CHANGELOG.md: v0.46.0 entry.
  • AGENTS.md: add a "Expression capture" section pointing at the new _emit_expression helpers and the dict shapes, so future agents know the contract.

Phase 6 — Verify

  1. poetry run pytest tests/grammar_test.py --tb=short -q — all grammar tests pass (existing 103 + new ~6).
  2. poetry run pytest tests/class_test.py tests/main_test.py tests/repr_test.py tests/navigate_test.py tests/semantic_test.py tests/partial_test.py tests/redefined_name_test.py tests/import_test.py --tb=short -q — full fast suite passes (412+ existing + new).
  3. poetry run pytest -m conformance --tb=no -q — 118/123 (same 5 pre-existing failures, byte-identical).
  4. If any previously-passing test fails, STOP and investigate (per AGENTS.md "if output changes for a passing test, stop").

6. Risk mitigations

Risk Mitigation
Existing strip_ws(text) == strip_ws(dump()) tests break Each emitter preserves child text via getText() or by recursing into structured children. Whitespace behavior shouldn't change. If a test breaks, STOP.
Deep operator precedence double-emits Write the deeply-nested test (a + b * c == 0) FIRST, before refactoring. Commit it as the regression bar.
354 grammar classes — adding new ones is small Audit first; most classes already exist. Only add what's missing.
Part.load_from_grammar prerequisite fix breaks other tests The fix only ADDS branches for previously-dropped usage kinds; it doesn't change existing branches. Existing tests for parts/items/attributes are unaffected.
dump() whitespace for binary operators differs from source Tolerated by strip_ws comparison. Document in STATUS.md.
The visitor refactor is large Keep _wrap_expression_layers as the skeleton builder; only change what gets spliced into the operator/operand arrays. Mechanical change.
Conformance baseline shifts Must stay at 118/123 with byte-identical failures. If it shifts, STOP.

7. What this enables (v0.47+)

A name resolver becomes feasible because every name reference inside an expression is now an individually-addressable FeatureReferenceExpression (with its FeatureChainExpression chain) rather than buried inside raw text.

v0.47+ name resolver sketch: 1. Walk assertConstraintUsage → calculationBody → resultExpressionMember → ownedExpression (recursive). 2. At each FeatureReferenceExpression, resolve the qualifiedName against the SymbolTable (already built by semantic.py). 3. At each FeatureChainExpression, resolve the source's type, then resolve each chain step on that type (extending _check_feature_chaining_compatible in semantic.py:1800). 4. Emit SemanticIssues for unresolved names.

v0.48+ type checker: verify operand types are compatible for each binary operator (e.g. == requires matching types, + requires numeric types).

v0.49+ OCL evaluation: evaluate the expression to a value (for assert constraint verification).

This is what gets you from "parse-clean but not checked" to "parse-clean AND names checked". Type-checking / OCL evaluation remain out of scope for v0.47.

8. Effort estimate

Phase Effort
Phase 0 (prerequisite: Part.load_from_grammar assert constraint) ~1 day
Phase 1 (visitor refactor: _emit_expression + helpers) ~3-4 days
Phase 2 (grammar class dump() fixes) ~1 day
Phase 3 (public API: find_expressions) ~0.5 day
Phase 4 (regression tests) ~0.5 day (write failing-first BEFORE Phase 1)
Phase 5 (documentation) ~0.5 day
Phase 6 (verify + fix regressions) ~1 day
Total ~7-8 days

9. File pointers (quick reference)

What Where
Lossy visitor function src/sysmlpy/antlr_visitor.py:10557 (_visit_owned_expression)
Empty-chain skeleton builder src/sysmlpy/antlr_visitor.py:10352 (_wrap_expression_layers)
Primary builders src/sysmlpy/antlr_visitor.py:10434-10535 (_make_literal_integer_primary, etc.)
Expression grammar src/sysmlpy/grammar/antlr4/SysMLv2Parser.g4:22-135 (ownedExpression, baseExpression, etc.)
Expression grammar classes src/sysmlpy/grammar/classes.py:5539-6350 (OwnedExpression through BaseExpression)
Part.load_from_grammar (prerequisite fix) src/sysmlpy/usage.py:782
Part.load_from_grammar children dispatch (missing assert) src/sysmlpy/usage.py:920-1078
Package-level assert dispatch (already works) src/sysmlpy/definition.py:1269
AssertConstraintUsage grammar class src/sysmlpy/grammar/classes.py:3373
CalculationBody grammar class src/sysmlpy/grammar/classes.py:3434
ResultExpressionMember grammar class src/sysmlpy/grammar/classes.py:3531
DefinitionBodyItem dispatch src/sysmlpy/grammar/classes.py:4935
OccurrenceUsageElement dispatch src/sysmlpy/grammar/classes.py:6439
BehaviorUsageElement dispatch src/sysmlpy/grammar/classes.py:3663
Issue #3 (this work unblocks finding 4) https://github.com/mycr0ft/sysmlpy/issues/3
AGENTS.md expression-capture section (to add) AGENTS.md (new section after "Common Pitfalls")

10. Open questions for implementation

  1. LiteralBoolean class: does it already exist in grammar/classes.py? If not, add it (mirrors LiteralInteger). Check during Phase 1.
  2. CastExpression / ConstructorExpression / MetadataAccessExpression / IfExpression: are these already reachable via PrimaryExpression.ownedRelationship1 / ownedRelationship2, or do they need new grammar classes? Audit during Phase 1; the ANTLR grammar has constructorExpression, metadataAccessExpression, and the IF … QUESTION … ELSE conditional alternative. The existing PrimaryExpression class already handles [], (), ., ->, # — these may be sufficient. Verify.
  3. Whitespace in dump(): should binary operators be rendered as lhs OP rhs (with spaces) or lhsOPrhs (no spaces)? SysML v2 textual notation typically uses spaces around binary operators. Check the OMG examples and match. The strip_ws test comparator tolerates either.
  4. FeatureChainExpression vs PrimaryExpression.ownedRelationship1: the existing PrimaryExpression class handles . chains via ownedRelationship1 / ownedRelationship2 (FeatureChainMember). Is a separate FeatureChainExpression class needed, or does PrimaryExpression already cover it? Audit during Phase 1; likely PrimaryExpression is sufficient and no new class is needed.
  5. Part.load_from_grammar prerequisite: should the new branches live in usage.py:920 (inside Part.load_from_grammar) or be hoisted into a shared helper called by both Part.load_from_grammar and Package.load_from_grammar? The package-level dispatch in definition.py:1269 already handles AssertConstraintUsage; consider extracting a shared _load_usage_child(self, inner_element) helper to avoid duplication. Decide during Phase 0.