Skip to content

feat: multi-axis ordered categories with per-axis comparator rendering#11

Merged
antonstefer merged 24 commits intomainfrom
claude/cool-pike
Apr 10, 2026
Merged

feat: multi-axis ordered categories with per-axis comparator rendering#11
antonstefer merged 24 commits intomainfrom
claude/cool-pike

Conversation

@antonstefer
Copy link
Copy Markdown
Owner

@antonstefer antonstefer commented Apr 10, 2026

Summary

  • Categories can be ordered: true with required orderingPhrases.comparators (all 7 constraint types)
  • Comparative constraints carry a required axis: string naming an ordered category
  • A puzzle can have multiple ordered axes — clues compare on whichever axis the constraint references
  • No fallbacks, no defaults: every ordered category fully owns its rendering phrases

What changed

Types: isPosition removed. ordered: true is the only ordering flag. ComparatorMap requires all 7 keys. OrderingPhrases.comparators required. SpatialWords, positionNoun, positionPreposition removed from Grid. New OrderedCategory type for narrowed returns from resolveAxis. Optional displayLabels on ordered categories for UI column headers.

Encoding: Positional fast path for the first ordered axis (identity-pinned), rank-forbidding encoder for others. Shared badBinaryRankPairs / encodeBinaryAxis / encodeBetweenAxis helpers.

Generator: Enumerates constraints per ordered axis in rank space. Auto-adds a default House category when no ordered category is provided.

Deduction: axisRankDomain / projectRanksToPositions helpers. Each comparative try* dispatches to rank-space propagation for non-first axes. Contradiction fallback covers propagation gaps.

Rendering: Per-axis comparator lookup via resolveAxis. No grid-level fallbacks — comparator missing = throw. at_position uses axis verb. AI prompt updated with rank-vs-value phrasing guidance, valueSuffix guidance for bare numerics.

Demo: 4 presets including "Hedge Funds (Multi-Axis)" with Year + Return both ordered. Nudge fix: skips steps whose eliminations are already applied.

Known follow-ups

  • Deduction explanation text uses generic "position"/"in" — should be axis-aware
  • AI-generated themes sometimes produce awkward phrasing — prompt iteration ongoing
  • Value lowercasing in objectValue is blanket — proper nouns get lowercased

Test plan

  • npm run check passes across all 3 workspaces (typecheck + lint + format + test)
  • 346 tests, 100% coverage (statements, branches, functions, lines) in both logic-grid and logic-grid-ai
  • Zero eslint-disable, zero as any, zero @ts-ignore
  • Demo: Default preset shows numbered column headers (1, 2, 3, 4) with "first house" in clues
  • Demo: Multi-axis preset renders clues on both Year and Return axes with per-axis phrasing
  • AI-generated themes produce domain-specific clue phrasing (tested with cooking, F1, space themes)

Introduce an `ordered` flag on Category and a required `axis` field on
comparative constraints (before, left_of, next_to, not_next_to, between,
not_between, exact_distance). `isPosition` is removed in favor of
`ordered: true` — at least one category per grid must be ordered, and
the auto-added House category fills that role when users provide none.

This is phase 1 of 5: the schema changes and test migration land now,
but the encoder, generator, deducer, and renderer still use row-based
logic. Phase 1 compensates by identity-pinning the first ordered
category in randomSolution, encodeBase, and createState so rows and
ranks agree for the default axis. Phase 2 will switch to rank-forbidding
encoding and remove the identity pinning.

Key additions:
- Category gains `ordered?: boolean` in a discriminated union that makes
  `numericValues` and `orderingPhrases` only legal on ordered categories.
- `positionAdjective` now requires `valueSuffix` via a union type.
- New axis.ts module: resolveAxis, orderedCategories, axisRank,
  displayAxisCategory, validateConstraints.
- Grid gains optional `displayAxis` hint (presentation concern).
- Grid loses `positionLabels` (derived on demand from first ordered
  category's values).
- SpatialWords keeps `atPosition` for the at_position render path.
- `renderSamePosition` gains a new rule: when one side has
  `positionAdjective` and the other is in an ordered category, render
  with the ordered value as subject and the adjective verb — recovering
  the classical Color+House "The first house is red" idiom from a plain
  same_position constraint.
- default-config.ts splits DEFAULT_CONFIG into DEFAULT_CATEGORIES,
  DEFAULT_SPATIAL_WORDS, DEFAULT_POSITION_NOUN, DEFAULT_POSITION_PREPOSITION,
  and exports `defaultHouseCategory(size)` for the auto-add.
- Generator's buildGrid auto-prepends the default House category as the
  first ordered slot (preserving total category count when the user
  asks for N categories without providing their own).
- logic-grid-ai: theme.ts schema and prompt replace `isPosition` with
  `ordered`, validation.ts rejects themes with no ordered category.
- Demo: PuzzleGrid and puzzle-state use `displayAxisCategory` instead of
  the removed `findPositionCategory`.

Multi-axis is not yet functional — the encoder/deducer/renderer only
consume the default (first ordered) axis in Phase 1. Phases 2-5 add
unified rank-forbidding encoding, multi-axis enumeration, rank-space
deduction, and per-axis rendering.
…phase 2)

Replace the 7 comparative encoder cases with a dispatch that uses:
- The cheap positional encoders when the constraint targets the first
  ordered category (which is identity-pinned by encodeBase).
- A shared rank-forbidding encoder for any other ordered axis.

The rank-forbidding encoder works by enumerating forbidden (rank_a, rank_b)
tuples for binary constraints (or (rank_o1, rank_o2, rank_middle) for
between/not_between) and emitting clauses that block each combination over
every compatible (p1, p2[, p3]) position tuple. The 4-literal clauses for
binary and 6-literal clauses for between correctly forbid the combinations
including edge cases where the middle's rank equals an outer's.

Key helpers added:
- badBinaryRankPairs(type, M, distance, numericValues) — enumerates the
  rank-pair combinations that violate each binary comparative type.
- encodeBinaryAxis(ctx, a, b, axis, badPairs) — emits 4-literal clauses.
- encodeBetweenAxis(ctx, o1, m, o2, axis, forbidStrictlyBetween) —
  emits 6-literal clauses. Handles the degenerate case where an outer's
  rank equals the middle's rank (auto-forbidden by construction).
- isIdentityPinnedAxis(grid, axis) — returns true for the first ordered
  category. Used to dispatch to the positional fast path.

The positional fast path is preserved as separate helpers (next_to,
not_next_to, left_of, before, between, not_between, exact_distance) and
delegated to when isIdentityPinnedAxis is true. Phase 4 will remove
identity pinning and switch exclusively to rank-forbidding; until then
the hybrid keeps single-axis puzzles fast while enabling multi-axis
encoding for non-first ordered categories.

Clause counts:
- Positional path (identity-pinned axis): same as before Phase 2, O(n²)
  for between, O(n) for other binaries.
- Rank-forbidding path: O(M²·n²) per binary constraint (≤ 2k clauses
  at M=n=8), O(M³·n³) per between constraint (~100k at M=n=8 — heavy
  but acceptable for small multi-axis puzzles; Phase 4+ can optimize).

Tests: 8 new encoder tests cover each of the 7 comparatives (plus a
numericValues variant) on a non-identity-pinned axis. Constructed via
a multi-axis grid where Year comes first (and is thus identity-pinned)
and Return comes second, forcing the rank-forbidding path for any
constraint referencing "Return".
Restructure enumerateConstraints to loop over every ordered category and
emit comparative constraints per-axis using rank-space relationships.
Previously the enumerator only emitted comparatives against the first
(identity-pinned) ordered category; now every declared `ordered: true`
category gets its own pass.

For each ordered axis:
- Build a rankAtRow[] map: the row → axis-value-index lookup derived from
  the solution's assignment of that axis category.
- Iterate cross-category value pairs, computing each value's rank on the
  current axis via rankAtRow[solutionPosition(value)]. Emit next_to,
  not_next_to, left_of, before, exact_distance when the rank relationship
  matches.
- Iterate triples for between / not_between similarly.
- Skip any value whose category is the current axis (trivially implied).

Axis-free same_position / not_same_position are enumerated once at the
top (unchanged shape). at_position / not_at_position are still emitted
only for values outside the first ordered category — matching the
encoder's identity-pinning assumption, which Phase 4 will remove.

Notable behaviors:
- Classic single-ordered-category puzzles (auto-House grids, Hedge Fund
  preset) produce byte-equivalent constraints to Phase 2. The outer
  per-axis loop runs once and the inner logic matches the previous
  single-axis enumeration.
- Multi-axis puzzles (two or more `ordered: true` categories) emit
  constraints on each axis. Minimization trims to whichever subset
  yields uniqueness; sweep tests confirm both axes surface in practice.

Test coverage:
- "constraints are consistent with the solution" updated to validate
  comparative constraints in rank space via a rankOf() helper that
  resolves the axis and finds the co-located axis value.
- New "generate with multiple ordered categories" suite:
  - generates a hedge-fund puzzle with Year + Return both ordered,
    confirms hasUniqueSolution and solver round-trip.
  - sweeps 20 seeds to confirm the generator is CAPABLE of emitting
    constraints on each axis (minimization may drop one axis for
    specific seeds; the capability check is what matters).
  - SAT round-trip sanity check on a multi-axis puzzle. Deduction
    completeness is deferred to Phase 4 (rank-space propagation).

The generator still calls classify() which calls deduce() — multi-axis
puzzles that involve non-first-axis constraints may currently stall in
deduce() and get labeled "expert". Phase 4 fixes this.
…se 4)

Add rank-space deduction helpers and update all 7 comparative try*
functions to dispatch through them when the constraint's axis is not
identity-pinned (i.e. not the first ordered category).

New helpers in deduce/state.ts:
- axisRankDomain(state, value, axis): computes the set of ranks `value`
  could occupy on `axis` by checking which axis values share a possible
  position with the value.
- projectRanksToPositions(state, value, axis, eliminatedRanks): projects
  rank eliminations back to position eliminations — a position is
  eliminated if its only possible axis value has an eliminated rank.

New helper in deduce/constraints.ts:
- tryBinaryRankSpace(): generic rank-space deduction for binary
  comparatives (before, left_of, next_to, not_next_to, exact_distance).
  Takes a predicate `isValid(rankA, rankB)` and eliminates ranks that
  have no valid partner. Projects back via projectRanksToPositions.
- tryBetweenRankSpace(): rank-space deduction for between/not_between
  on ternary constraints. Eliminates ranks for middle and outers that
  can't participate in any valid rank triple.
- isIdentityPinned(): returns true for the first ordered category.
  Used to dispatch to the existing positional fast path when applicable.

Each try* function dispatches:
- Identity-pinned axis → existing positional logic (unchanged behavior,
  equivalent to pre-Phase-4).
- Non-pinned axis → rank-space path via tryBinaryRankSpace or
  tryBetweenRankSpace.

The rank-space propagation is correct but weaker than full positional
propagation in corner cases — it projects through axisRankDomain which
loses joint-possibility information. The existing SAT-based contradiction
fallback (deduce/contradiction.ts) closes any propagation gaps, exactly
as it does for positional constraints today.

Tests: new "rank-space deduction on non-pinned axis" suite exercises
the rank-space path directly via tryConstraint with a pre-configured
state, confirming that before(Alice, Bob, Return) correctly eliminates
Alice's position when her only possible Return rank is the maximum.
Thread the constraint's axis through the comparator and unit lookup in
templates.ts. Each comparative constraint now checks the axis category's
orderingPhrases.comparators first, falling back to grid-level
spatialWords.comparators, then to default spatial-word composition.
exact_distance additionally checks axis.orderingPhrases.unit before
grid.spatialWords.distanceUnit before grid.positionNoun.

This means a multi-axis puzzle where Year has
  orderingPhrases.comparators.before = ["was begun earlier than", ...]
and Return has
  orderingPhrases.comparators.before = ["has a lower return than", ...]
will render clues on each axis using the correct domain-specific phrasing
without cross-contamination.

Tests: new "per-axis orderingPhrases in rendering" suite verifies:
- before uses Year comparators when axis=Year, Return when axis=Return
- next_to uses Year comparator when axis=Year, falls through to grid
  default when axis=Return (which has no next_to override)
- exact_distance uses per-axis unit for Return, grid default for Year

327 tests passing across all 3 workspaces.
Fix at_position rendering to use the first ordered category's verb
instead of generic spatialWords.atPosition. The value is always subject
and the axis value is always object, preventing wrong verb assignment.
Remove now-unused positionLabel helper.

Fix rank-adjacency comparator phrasing across all demo presets and the
AI prompt example. next_to and left_of mean "adjacent in rank order"
(no other value between them), NOT "close in value" or "exactly 1 unit
apart". With non-equidistant numericValues, rank-adjacent values can
have different gaps, so phrasing must not imply specific value distances.

Phrasing rules applied:
- next_to: "right before or after" (temporal), "right above or below"
  (numeric scales). Never "closest", "nearest", "adjacent to", "within
  N units of".
- left_of: "right before" / "right after", "the next higher/lower".
  Never "exactly one X before/after".
- exact_distance: the only constraint that may claim specific value
  gaps, via numericValues + unit.

Add AI prompt guidance explaining the rank-vs-value distinction so
AI-generated themes avoid the same fallacy.

Add "Hedge Funds (Multi-Axis)" demo preset with Year and Return both
ordered, numericValues on Year for value-based exact_distance, and
complete comparator phrase sets using correct rank-order phrasing.
findNextStep treated confirmed cells in the elimination list as pending
work, so nudge kept pointing at a clue after the user correctly filled
it in. Now only empty cells count as unapplied eliminations.
Update constraint factory signatures (axis parameter), rename
same_house to same_position throughout, add multi-axis example with
Year and Return both ordered, document orderingPhrases/numericValues,
update types section and constraint types table.
Add axis.ts unit tests, factory tests for atPosition/notAtPosition,
generator validation tests (duplicate names, numericValues length,
invalid displayAxis), and rank-space deduction tests for left_of,
between, not_between, exact_distance (with and without numericValues)
on non-pinned axes.

Replace defensive silent fallbacks with throws in encoding.ts,
templates.ts, and deduce/state.ts — missing ordered category now
surfaces immediately instead of producing wrong output.

Simplify templates.ts comparator/unit lookup: use resolveAxis instead
of redundant find + ordered check, eliminating unreachable branches.
Make comparator helper params required (axis is always provided).

Add AI validation test for missing ordered category.

Coverage: 100% statements, branches, functions, lines in both
logic-grid and logic-grid-ai.
Remove SpatialWords from Grid and all grid-level rendering defaults.
Comparative clue rendering now comes exclusively from each ordered
category's orderingPhrases.comparators — no fallbacks, no defaults.

Type-level enforcement:
- ComparatorMap is now Record (all 7 keys required), not Partial
- OrderingPhrases.comparators is required, not optional
- orderingPhrases is required on ordered categories
- OrderedCategory type returned by resolveAxis gives full access
  without non-null assertions
- Validator accepts RawCategory (loose) for untrusted AI input

Removed: SpatialWords, Grid.positionNoun, Grid.positionPreposition,
Grid.spatialWords, GenerateOptions.positionNoun/positionPreposition,
DEFAULT_SPATIAL_WORDS, DEFAULT_POSITION_NOUN, DEFAULT_POSITION_PREPOSITION,
ThemeResult.positionNoun/positionPreposition, posNoun/posPrep/posNounPlural.

Default House carries full comparators and unit. Deduction explanations
use generic "position"/"in" (follow-up to make axis-aware).

AI prompt updated to require comparators on ordered categories with
good/bad phrasing examples and rank-vs-value guidance.

Zero eslint-disables. Zero as-any casts. 100% coverage.
Add prompt guidance: orderingPhrases.unit only applies to
exact_distance clues. Bare numeric values like "500" that need a
label in all clue types should use valueSuffix instead (e.g.
valueSuffix: "gold pieces" → "has a bounty of 500 gold pieces").
Self-explanatory values like "2005" or "7am" need no suffix.
Add optional displayLabels to ordered categories. When present, the
demo uses these for column headers instead of values. Clue rendering
always uses values.

Default House category gets displayLabels: ["1", "2", "3", ...] so
the grid shows house numbers while clues say "the first house".
Other presets (Hedge Fund) show their actual values (1972, 1983...).

Also fix displayAxisCategory return type to OrderedCategory.
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Apr 10, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
logic-grid 1a57171 Commit Preview URL

Branch Preview URL
Apr 10 2026, 06:42 PM

@antonstefer antonstefer changed the title feat: multi-axis ordered categories support (phases 1-5) feat: multi-axis ordered categories with per-axis comparator rendering Apr 10, 2026
- Add complexity comment on encodeBetweenAxis (O(M³·n³), acceptable
  for grid sizes 3-8)
- Cap next_to constraint count in per-axis enumeration (was uncapped,
  could produce disproportionately many constraints with multiple axes)
- Add doc comment on orderedCategories (never throws, returns empty)
- Clean up stale phase comments — describe current behavior, not
  historical phases
- Add comment explaining displayLabels vs values on House category
- Add multi-axis generator test exercising both encoder paths
- Clean up ${"in"}/${"position"} template noise in deduction
  explanations — use plain string interpolation
- Add isOrdered type predicate, use in orderedCategories and
  resolveAxis instead of as-casts
- Remove redundant resolveAxis call in tryExactDistance
- Remove TEST_COMPARATORS duplication — generator.test.ts imports
  from test-helpers.ts
- Export isOrdered from index.ts
Move verb from CategoryCore (optional) into the OrderednessFields
discriminated union: required on ordered: true, optional on unordered.
This makes it a compile-time error to construct an ordered category
without a verb, eliminating the runtime throw in templates.ts.

Remove the now-unnecessary runtime checks and their tests.
ComparatorMap is now an interface with precise types per key:
- before, left_of: [string, string] (directional, tuple required)
- next_to, not_next_to, between, not_between, exact_distance: string
  (symmetric, plain string only)

This eliminates the runtime checkComparators validation in the
generator — the type system catches symmetric-tuple errors at compile
time. The AI validation layer keeps its runtime check since AI JSON
output is untyped.

Remove ComparatorPhrase type (unused). Simplify directionalComp
(always receives a tuple). Remove SYMMETRIC_COMPARATORS set and
checkComparators function from generator.
The generator could return a puzzle with 0 constraints when the
minimizer couldn't achieve uniqueness — observed on CI with
Math.random. Now it retries on empty constraint sets.

Seed 4 previously-unseeded generate() calls to prevent
platform-dependent flakiness.
@antonstefer antonstefer merged commit 0df2d69 into main Apr 10, 2026
4 checks passed
@antonstefer antonstefer deleted the claude/cool-pike branch April 10, 2026 18:48
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