feat: multi-axis ordered categories with per-axis comparator rendering#11
Merged
antonstefer merged 24 commits intomainfrom Apr 10, 2026
Merged
feat: multi-axis ordered categories with per-axis comparator rendering#11antonstefer merged 24 commits intomainfrom
antonstefer merged 24 commits intomainfrom
Conversation
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.
Deploying with
|
| 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 |
- 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ordered: truewith requiredorderingPhrases.comparators(all 7 constraint types)axis: stringnaming an ordered categoryWhat changed
Types:
isPositionremoved.ordered: trueis the only ordering flag.ComparatorMaprequires all 7 keys.OrderingPhrases.comparatorsrequired.SpatialWords,positionNoun,positionPrepositionremoved from Grid. NewOrderedCategorytype for narrowed returns fromresolveAxis. OptionaldisplayLabelson 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/encodeBetweenAxishelpers.Generator: Enumerates constraints per ordered axis in rank space. Auto-adds a default House category when no ordered category is provided.
Deduction:
axisRankDomain/projectRanksToPositionshelpers. Each comparativetry*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_positionuses 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
objectValueis blanket — proper nouns get lowercasedTest plan
npm run checkpasses across all 3 workspaces (typecheck + lint + format + test)eslint-disable, zeroas any, zero@ts-ignore