Skip to content

spytial-core 3.2.1 + inferredEdge draw; document the decorators; align hold/projection/value-sets#128

Merged
sidprasad merged 4 commits into
mainfrom
decorator-docs-and-group-alignment
Jul 16, 2026
Merged

spytial-core 3.2.1 + inferredEdge draw; document the decorators; align hold/projection/value-sets#128
sidprasad merged 4 commits into
mainfrom
decorator-docs-and-group-alignment

Conversation

@sidprasad

@sidprasad sidprasad commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Started from "@group doesn't seem to have the addEdge/direction stuff I need — or at least it's not well documented." The feature was entirely there and correct; it was invisible. Pulling that thread found a family of related problems, all the same shape: a value that looks applied but isn't.

Ships spytial-py 1.8.0 on spytial-core 3.2.1.

spytial-core 3.1.1 → 3.2.1, and draw

3.2.0-beta.1 was never published — npm went straight to 3.2.1. Pin, vendored evaluator and version docstring move via update-spytial-core.sh. The vendored evaluator.js is byte-identical across the bump (draw is a layout concern, not an evaluator one); confirmed by diffing a fresh npm pack of both, so the empty diff is real rather than a copy that silently no-op'd.

New: draw: '<end> -> <end>' on inferredEdge. Each end is _ or a group-constraint name; a named end attaches to the hull of the group keyed by that end's atom. It's the first way to express group-to-group and node-to-group edges. The shape is validated at the authoring site (core rejects a malformed draw too, but only once the spec reaches the browser); whether a named group exists needs the whole spec and may be declared on another class, so that stays core's check.

Verified by rendering a real diagram against the published 3.2.1 bundle and reading the geometry back out of the DOM — not by round-tripping YAML alone:

edge 'reports to': stroke=crimson, dash=6,4, width=3px   <- the lineStyle authored in Python
start [573,350] on hull perimeter x=572        -> true
end   [353,350] on hull perimeter x=197+156    -> true

That render caught a bad example this PR would otherwise have shipped — see below.

The original problem

Every decorator is built by _create_decorator and takes **kwargs, so help() and IDE hover showed decorator(**kwargs) and nothing else. The reference material lived on the annotation classes (the Annotated[...] form) — not where you look when typing @spytial.group(. group's addEdge has supported none/togroup/fromgroup plus the GroupEdge block since #124/#127, with no way to discover it from the call site.

_create_decorator now takes doc= and sets __name__/__qualname__/__doc__. All 17 decorators document exactly the keys core reads — verified against core's real parseLayoutSpec, not the existing prose.

What verification found

Closed value sets were unchecked, and core fails silently on three of four. The direction unions are TypeScript types — erased at runtime, so nothing re-checks them:

key bad value what core does
cyclic.direction 'widdershins' kept, read as clockwise — a typo'd counterclockwise silently spins the other way
orientation.directions ['bogus'] kept, matches no case — constraint silently evaporates
flag.name typo silent no-op
align.direction 'sideways' core throws (the only checked one)

Now enforced on every authoring form, including the Annotated[...] classes via one hook in SpytialAnnotation, so the two forms can't disagree.

The check found 25 call sites that were already wrong — 6 test files, a demo, pre_publish_check.py, the copilot instructions — all using align's axis words as orientation directions, i.e. asserting on constraints that do nothing. They trace to a wrong example in the Orientation docstring, propagated by copy. Flag(name='debug') and Align(direction='left') were the same. That's the argument for the check: the docs taught the wrong vocabulary and nothing downstream objected.

hold='never' was broken. Core's only negation mechanism, but CONSTRAINT_TYPES didn't list it — every use printed Warning: Unknown fields while quietly working. Now accepted, validated, with the no-op always dropped to match the class form.

There were three **kwargs paths, not two. annotate_type_alias open-coded the desugar sequence and so never got the hold fix or the projection warning. All three now share _prepare_kwargs.

suggest restated the vocabulary and got it wrong. _ORIENT_DIRS omitted directlyAbove/directlyBelow, so a model proposing either had a direction core handles filtered out from under it. It now sources the canonical set.

projection is a no-op — deprecated. Core's parser never reads it: projection: {sig: Node} yields zero directives and no trace. It's a pre-layout transform driven by the viewer's controls; core's own module says it is "intentionally decoupled from the layout engine." Now warns on all four authoring paths, exactly once each. Still serializes: deprecation, not removal.

group's field form listed showLabel, which core never reads.

A draw example that didn't work. draw needs a group from a binary selector, since it resolves each end by the group keyed by that atom. My example { t : Team, m : Member | m in t.members } forms no groups for a List[Member] field (members relationalizes as Team -> list -> Member, so m in t.members never holds), and the docs' usual Team.members idiom is unary — one unkeyed group, against which draw is dropped with no edge, no warning, no error. Only rendering it surfaced this. Examples now use a keyed binary selector and both docs and docstring state the requirement.

Left strict, documented rather than changed

@icon requires showLabels (core reads missing as False; the Icon class defaults it to True — looks like an oversight), @cyclic requires direction (core defaults clockwise), @group requires name even when negated (core synthesizes one). Happy to loosen any separately.

Testing

443 pass, up from 323. New test/test_decorator_docs.py covers the decorator surface, the value sets across all four authoring paths (including that the check isn't narrower than core), hold, projection, and draw. Every @spytial.* example in docs/ is executed against the validator.

One judgment call: out-of-vocab values raise rather than warn. It's a behavior change for anyone passing one today — but they're currently getting a constraint that does nothing, or the opposite direction.

🤖 Generated with Claude Code

Every decorator is built by _create_decorator and takes **kwargs, so help()
and IDE hovers showed `decorator(**kwargs)` and nothing else. The reference
docs lived only on the annotation *classes* (the Annotated[...] form), which
is not where you look when writing `@spytial.group(`. That made a complete
feature read as missing: group's addEdge has supported the three directions
and the GroupEdge style block since #124/#127, but you could not see it.

_create_decorator now takes doc= and sets __name__/__qualname__/__doc__, and
all 17 decorators document exactly the keys spytial-core reads -- verified by
parsing the emitted YAML with core's parseLayoutSpec rather than trusting the
existing prose.

That verification turned up three divergences from core:

- hold='never' is core's only negation mechanism (parseConstraints reads it
  off the inner block), but CONSTRAINT_TYPES omitted it, so every use printed
  "Unknown fields" while quietly working. Now accepted on all four
  constraints, and documented in docs/operations.md, which never covered it.

- projection is a no-op. Core's layout-spec parser does not read it at all --
  projection is a pre-layout transform over the data instance, driven by the
  viewer's controls, and core's projection-transform module is explicit that
  it is "intentionally decoupled from the layout engine". Authoring one
  emitted YAML core discards. Now warns on all four authoring paths (exactly
  once each); still serializes, since this is a deprecation, not a removal.

- group's field form listed showLabel, which core never reads: it builds
  GroupByField(field, groupOn, addToGroup, selector, negated) and derives
  label visibility from negation alone.

Also fixes two class docstrings whose examples core rejects: Align advertised
direction='left' (align takes only horizontal/vertical -- core raises), and
Orientation advertised directions=['horizontal'] (matches no case in core's
direction switch, so it is silently dropped). The two vocabularies had been
swapped.

Where spytial-py is deliberately stricter than core -- @ICON requires
showLabels, @Cyclic requires direction, @group requires name even when
negated -- the docstrings say so rather than promise core's defaults.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sidprasad

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 06a858e647

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread spytial/annotations.py
Comment on lines +24 to +26
"cyclic": {"required": ["selector", "direction"], "optional": ["hold"]},
"orientation": {"required": ["selector", "directions"], "optional": ["hold"]},
"align": {"required": ["selector", "direction"], "optional": ["hold"]},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate hold values before serializing constraints

When the decorator/object/type-alias paths now accept hold, they only pass through validate_fields, so values are not checked or normalized like the Orientation/Cyclic/Align/Group constructors do. In those paths, @spytial.orientation(..., hold='neevr') or hold='always' is stored verbatim in the emitted YAML; since the new comment says core only flips the constraint for hold: never, a typo silently leaves the constraint positive/default instead of negated. Please validate the new kwarg on these paths and omit the default always before appending entries.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good catch — confirmed and fixed in f67a9e1.

You're right that adding hold to CONSTRAINT_TYPES only taught validate_fields the key was allowed; it never looks at values. Reproduced both halves, and confirmed the consequence against core's parser:

hold: never   -> negated = true
hold: neevr   -> negated = false   # silently positive
hold: always  -> negated = false

So @spytial.orientation(..., hold='neevr') emitted a constraint that stayed positive — the inverse of what was written, with no error. That's the same silent-divergence class of bug this PR set out to close, so it's squarely in scope rather than a nitpick.

The Annotated[...] classes already had the right contract (reject unknown values, omit the no-op always), so rather than restate it I extracted _validate_hold/_normalize_hold, called from the decorator and _annotate_object — the two **kwargs choke points, covering @decorator on classes and objects plus annotate(). The four class constructors now share the helper instead of carrying four copies of the same check. Normalization is idempotent, since the decorator-on-object path passes through both.

On dropping always: agreed, and worth noting it was a real inconsistency, not just noise — the class form omitted it while the decorator emitted it, so the same spec differed depending on which form authored it. Both omit now.

26 new tests cover it across all four constraints and both paths; 399 pass.

sidprasad and others added 3 commits July 16, 2026 07:23
Codex caught that adding `hold` to CONSTRAINT_TYPES only taught
validate_fields the key was allowed -- it never checks values. So the
decorator and object paths took `hold` as an opaque string and passed it
straight into the YAML, where core reads anything that isn't exactly
'never' as "not negated". `@spytial.orientation(..., hold='neevr')`
therefore emitted a constraint that silently stayed positive: the inverse
of what was written, with no error and nothing to see.

Verified against core: hold=never -> negated=true, hold=neevr -> false.

The Annotated[...] classes already had the right contract (reject unknown
values, omit the no-op 'always'), so the fix is to share it rather than
restate it: _validate_hold/_normalize_hold, called from the decorator and
_annotate_object, with the four class constructors folded onto the same
helper instead of four copies of the same check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generalizes the hold fix: hold was one instance of a closed value set that
nothing checked. Core's RelativeDirection / RotationDirection /
AlignDirection are TypeScript unions, so they are erased at runtime and no
downstream code re-checks them. Verified what core actually does with a
value outside each set:

  orientation.directions  ['bogus'] -> kept, matches no case, constraint
                                       silently evaporates
  cyclic.direction        'widdershins' -> kept, read as 'clockwise', so a
                                       typo'd 'counterclockwise' silently
                                       spins the other way
  flag.name               typo -> silent no-op
  align.direction         'sideways' -> core throws (the only checked one)

Three of four fail silently, so the authoring site is the only place they
can surface. ORIENTATION_DIRECTIONS / ROTATION_DIRECTIONS /
ALIGN_DIRECTIONS / FLAG_NAMES now mirror core's unions and are enforced on
every authoring form -- including the Annotated[...] classes, via one hook
in SpytialAnnotation, so the two forms cannot disagree.

Two things fell out of doing it:

- There were three **kwargs paths, not two. annotate_type_alias open-coded
  the desugar/coerce sequence and so never got the hold fix or the
  projection warning -- the drift Codex pointed at, one path over. All
  three now share _prepare_kwargs.

- suggest's _ORIENT_DIRS restated the vocabulary and omitted directlyAbove
  and directlyBelow, so a model proposing either had a direction core
  handles filtered out from under it. It now sources the canonical set.

The check immediately found 25 call sites across 6 test files, a demo,
pre_publish_check and the copilot instructions using align's axis words as
orientation directions -- i.e. every one of them was asserting on a
constraint that does nothing. That is the Orientation docstring bug from
the previous commit, propagated by copy. Fixed, along with Flag(name=
'debug') and Align(direction='left'), which were the same thing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3.2.0-beta.1 was never published — npm went straight to 3.2.1, now `latest`.
Pin, vendored evaluator and version docstring all move via
update-spytial-core.sh. (The vendored evaluator.js is byte-identical
3.1.1 -> 3.2.1: draw is a layout/render concern, not an evaluator one.
Confirmed by diffing a fresh `npm pack` of both, so the empty diff is real
and not a copy that silently no-op'd.)

Adds `draw: '<end> -> <end>'` to inferredEdge — each end `_` or a group
constraint name, a named end attaching to the hull of the group keyed by
that end's atom. This is the first way to express group-to-group and
node-to-group edges. The shape is validated at the authoring site (core
rejects a malformed draw too, but only once the spec reaches the browser);
whether a named group *exists* needs the whole spec and may be declared on
another class, so that check stays core's.

Verified end to end rather than by round-tripping YAML alone: rendered a
real diagram against the published 3.2.1 bundle and read the geometry back
out of the DOM. The edge is crimson / 6,4 dashed / 3px — the lineStyle as
authored in Python — and both endpoints sit on group-hull perimeters.

That render caught a bad example this commit would otherwise have shipped.
`draw` resolves an end by looking up the group of that name *keyed by* that
end's atom, so the group must come from a binary selector. The example used
`{ t : Team, m : Member | m in t.members }`, which forms no groups at all
for a List[Member] field (members relationalizes as Team -> list -> Member,
so `m in t.members` never holds); and the docs' usual `Team.members` idiom
is unary, giving one unkeyed group, against which draw is dropped with no
edge, no warning and no error. Examples now use a keyed binary selector,
and both the docs and the docstring say the key is the requirement.

Version: minor, matching the convention for a core bump that brings new
capability (#127 core 3.1.0 -> 1.7.0; #105 core 2.9.0 -> 1.5.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sidprasad sidprasad changed the title Document the decorators; align group/hold/projection with spytial-core spytial-core 3.2.1 + inferredEdge draw; document the decorators; align hold/projection/value-sets Jul 16, 2026
@sidprasad
sidprasad merged commit a04e3be into main Jul 16, 2026
2 checks passed
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