From d06408ce2a7a0283abae58a47721b868928ae87d Mon Sep 17 00:00:00 2001 From: davideagosti-dev Date: Thu, 27 Aug 2026 20:07:46 +0100 Subject: [PATCH 1/3] feat(intelligence): add deterministic layout planner Introduce private @dndgem/intelligence with prominence-weighted automatic item ordering, normalization trust boundary, and optional Core automaticItemOrder input for Auto-Layout while preserving Phase 3 defaults. Co-authored-by: Cursor --- .changeset/dnd-4-2-automatic-item-order.md | 5 + benchmarks/core/intelligence-planner.bench.ts | 155 ++++++++++++ .../intelligence-planner.semantic.test.ts | 95 ++++++++ benchmarks/vitest.bench.config.ts | 1 + benchmarks/vitest.config.ts | 1 + docs/architecture/alpha-api-contract.md | 4 +- docs/architecture/deterministic-planner.md | 164 +++++++++++++ docs/architecture/package-boundaries.md | 14 +- docs/roadmap.md | 23 +- eslint.config.js | 42 ++++ package.json | 2 +- packages/core/src/auto-layout.ts | 21 +- packages/core/src/automatic-item-order.ts | 96 ++++++++ .../core/tests/automatic-item-order.test.ts | 184 +++++++++++++++ packages/intelligence/package.json | 30 +++ packages/intelligence/src/index.ts | 16 ++ packages/intelligence/src/normalize.ts | 125 ++++++++++ packages/intelligence/src/planner.ts | 35 +++ packages/intelligence/src/types.ts | 28 +++ packages/intelligence/tests/boundary.test.ts | 46 ++++ .../intelligence/tests/integration.test.ts | 223 ++++++++++++++++++ packages/intelligence/tests/normalize.test.ts | 70 ++++++ packages/intelligence/tests/planner.test.ts | 120 ++++++++++ packages/intelligence/tsconfig.build.json | 13 + packages/intelligence/tsconfig.json | 8 + packages/intelligence/vitest.config.ts | 17 ++ pnpm-lock.yaml | 13 + scripts/check-boundaries.mjs | 64 ++++- scripts/package-topology.mjs | 5 + 29 files changed, 1590 insertions(+), 30 deletions(-) create mode 100644 .changeset/dnd-4-2-automatic-item-order.md create mode 100644 benchmarks/core/intelligence-planner.bench.ts create mode 100644 benchmarks/core/intelligence-planner.semantic.test.ts create mode 100644 docs/architecture/deterministic-planner.md create mode 100644 packages/core/src/automatic-item-order.ts create mode 100644 packages/core/tests/automatic-item-order.test.ts create mode 100644 packages/intelligence/package.json create mode 100644 packages/intelligence/src/index.ts create mode 100644 packages/intelligence/src/normalize.ts create mode 100644 packages/intelligence/src/planner.ts create mode 100644 packages/intelligence/src/types.ts create mode 100644 packages/intelligence/tests/boundary.test.ts create mode 100644 packages/intelligence/tests/integration.test.ts create mode 100644 packages/intelligence/tests/normalize.test.ts create mode 100644 packages/intelligence/tests/planner.test.ts create mode 100644 packages/intelligence/tsconfig.build.json create mode 100644 packages/intelligence/tsconfig.json create mode 100644 packages/intelligence/vitest.config.ts diff --git a/.changeset/dnd-4-2-automatic-item-order.md b/.changeset/dnd-4-2-automatic-item-order.md new file mode 100644 index 0000000..41a423c --- /dev/null +++ b/.changeset/dnd-4-2-automatic-item-order.md @@ -0,0 +1,5 @@ +--- +'@dndgem/core': minor +--- + +Add optional `automaticItemOrder` to `AutoLayoutProposalInput` for advisory automatic-item processing order (DND-4.2). Default Auto-Layout behavior is unchanged when omitted. diff --git a/benchmarks/core/intelligence-planner.bench.ts b/benchmarks/core/intelligence-planner.bench.ts new file mode 100644 index 0000000..667bb45 --- /dev/null +++ b/benchmarks/core/intelligence-planner.bench.ts @@ -0,0 +1,155 @@ +/** + * DND-4.2 intelligence planner benchmarks — timing evidence only (not CI gates). + */ +import { bench, describe } from 'vitest'; +import { + createAutoLayoutProposal, + createLayoutIntent, + createLayoutItem, + solveLayout, +} from '@dndgem/core'; +import { + createDeterministicPlanningProposal, + normalizePlanningProposal, +} from '@dndgem/intelligence'; + +function buildSmallFixture() { + return createLayoutIntent({ + space: { width: 200, height: 100 }, + items: [ + createLayoutItem({ + id: 'blocker', + constraints: { preferredWidth: 120, preferredHeight: 100, minWidth: 40 }, + }), + createLayoutItem({ + id: 'target-a', + constraints: { preferredWidth: 90, preferredHeight: 100, minWidth: 40 }, + }), + createLayoutItem({ + id: 'target-b', + constraints: { preferredWidth: 90, preferredHeight: 100, minWidth: 40 }, + }), + ], + }); +} + +function buildMediumFixture(itemCount = 20) { + const items = []; + for (let i = 0; i < itemCount; i += 1) { + items.push( + createLayoutItem({ + id: `item-${i}`, + constraints: { + preferredWidth: 60 + (i % 5) * 10, + preferredHeight: 40 + (i % 3) * 8, + minWidth: 20, + }, + }), + ); + } + return createLayoutIntent({ + space: { width: 800, height: 600 }, + items, + }); +} + +function prominenceFor(items: readonly { id: string }[]) { + const prominence: Record = {}; + for (let i = 0; i < items.length; i += 1) { + prominence[items[i]!.id] = items.length - i; + } + return prominence; +} + +describe('DND-4.2 intelligence planner benchmarks — timing', () => { + const smallIntent = buildSmallFixture(); + const smallSnapshot = { + intent: smallIntent, + prominence: { 'target-a': 10, 'target-b': 5, blocker: 0 }, + }; + + bench( + 'planner-only (small order-sensitive)', + () => { + createDeterministicPlanningProposal(smallSnapshot); + }, + { warmupIterations: 20, iterations: 200, time: 0 }, + ); + + bench( + 'planner + normalize + auto-layout (small)', + () => { + const order = normalizePlanningProposal( + smallSnapshot, + createDeterministicPlanningProposal(smallSnapshot), + ).automaticItemOrder; + createAutoLayoutProposal({ intent: smallIntent, automaticItemOrder: order }); + }, + { warmupIterations: 20, iterations: 100, time: 0 }, + ); + + bench( + 'planner + auto-layout + solveLayout (small)', + () => { + const order = normalizePlanningProposal( + smallSnapshot, + createDeterministicPlanningProposal(smallSnapshot), + ).automaticItemOrder; + const proposal = createAutoLayoutProposal({ intent: smallIntent, automaticItemOrder: order }); + solveLayout({ intent: proposal.effectiveIntent }); + }, + { warmupIterations: 20, iterations: 100, time: 0 }, + ); + + const mediumIntent = buildMediumFixture(20); + const mediumSnapshot = { + intent: mediumIntent, + prominence: prominenceFor(mediumIntent.items), + }; + + bench( + 'planner-only (medium ~20 items)', + () => { + createDeterministicPlanningProposal(mediumSnapshot); + }, + { warmupIterations: 10, iterations: 100, time: 0 }, + ); + + bench( + 'planner + auto-layout + solveLayout (medium ~20 items)', + () => { + const order = normalizePlanningProposal( + mediumSnapshot, + createDeterministicPlanningProposal(mediumSnapshot), + ).automaticItemOrder; + const proposal = createAutoLayoutProposal({ + intent: mediumIntent, + automaticItemOrder: order, + }); + solveLayout({ intent: proposal.effectiveIntent }); + }, + { warmupIterations: 10, iterations: 50, time: 0 }, + ); + + const largeIntent = buildMediumFixture(35); + const largeSnapshot = { + intent: largeIntent, + prominence: prominenceFor(largeIntent.items), + }; + + bench( + 'planner + auto-layout + solveLayout (~35 items)', + () => { + const order = normalizePlanningProposal( + largeSnapshot, + createDeterministicPlanningProposal(largeSnapshot), + ).automaticItemOrder; + const proposal = createAutoLayoutProposal({ + intent: largeIntent, + automaticItemOrder: order, + }); + solveLayout({ intent: proposal.effectiveIntent }); + }, + { warmupIterations: 5, iterations: 30, time: 0 }, + ); +}); diff --git a/benchmarks/core/intelligence-planner.semantic.test.ts b/benchmarks/core/intelligence-planner.semantic.test.ts new file mode 100644 index 0000000..38e8c4a --- /dev/null +++ b/benchmarks/core/intelligence-planner.semantic.test.ts @@ -0,0 +1,95 @@ +/** + * DND-4.2 intelligence planner semantic benchmarks — CI-safe checks only. + */ +import { describe, expect, it } from 'vitest'; +import { createAutoLayoutProposal, createLayoutIntent, createLayoutItem } from '@dndgem/core'; +import { + createDeterministicPlanningProposal, + normalizePlanningProposal, +} from '@dndgem/intelligence'; + +function orderSensitiveIntent() { + return createLayoutIntent({ + space: { width: 200, height: 100 }, + items: [ + createLayoutItem({ + id: 'blocker', + constraints: { + preferredWidth: 120, + preferredHeight: 100, + minWidth: 40, + }, + }), + createLayoutItem({ + id: 'target-a', + constraints: { + preferredWidth: 90, + preferredHeight: 100, + minWidth: 40, + }, + }), + createLayoutItem({ + id: 'target-b', + constraints: { + preferredWidth: 90, + preferredHeight: 100, + minWidth: 40, + }, + }), + ], + }); +} + +describe('DND-4.2 intelligence planner — semantic gates', () => { + it('planner guidance reduces unplaced automatic items on order-sensitive fixture', () => { + const intent = orderSensitiveIntent(); + const snapshot = { + intent, + prominence: { 'target-a': 10, 'target-b': 5, blocker: 0 }, + }; + const baseline = createAutoLayoutProposal({ intent }); + const guided = createAutoLayoutProposal({ + intent, + automaticItemOrder: normalizePlanningProposal( + snapshot, + createDeterministicPlanningProposal(snapshot), + ).automaticItemOrder, + }); + + expect(baseline.unplacedItemIds.length).toBeGreaterThan(guided.unplacedItemIds.length); + expect(guided.unplacedItemIds).toEqual(['blocker']); + }); + + it('planner-only output is deterministic across repeated runs (50×)', () => { + const intent = orderSensitiveIntent(); + const snapshot = { + intent, + prominence: { 'target-a': 10, 'target-b': 5, blocker: 0 }, + }; + const first = createDeterministicPlanningProposal(snapshot); + for (let i = 0; i < 50; i += 1) { + expect(createDeterministicPlanningProposal(snapshot)).toEqual(first); + } + }); + + it('end-to-end planner + auto-layout proposal order stays stable (50×)', () => { + const intent = orderSensitiveIntent(); + const snapshot = { + intent, + prominence: { 'target-a': 10, 'target-b': 5, blocker: 0 }, + }; + + const run = () => { + const order = normalizePlanningProposal( + snapshot, + createDeterministicPlanningProposal(snapshot), + ).automaticItemOrder; + return createAutoLayoutProposal({ intent, automaticItemOrder: order }); + }; + + const first = run(); + for (let i = 0; i < 50; i += 1) { + expect(run()).toEqual(first); + } + }); +}); diff --git a/benchmarks/vitest.bench.config.ts b/benchmarks/vitest.bench.config.ts index 7f222ea..122bd26 100644 --- a/benchmarks/vitest.bench.config.ts +++ b/benchmarks/vitest.bench.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ resolve: { alias: { '@dndgem/core': path.resolve(root, '../packages/core/dist/index.js'), + '@dndgem/intelligence': path.resolve(root, '../packages/intelligence/dist/index.js'), }, }, }); diff --git a/benchmarks/vitest.config.ts b/benchmarks/vitest.config.ts index b08e91d..bc7cdac 100644 --- a/benchmarks/vitest.config.ts +++ b/benchmarks/vitest.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ resolve: { alias: { '@dndgem/core': path.resolve(root, '../packages/core/dist/index.js'), + '@dndgem/intelligence': path.resolve(root, '../packages/intelligence/dist/index.js'), }, }, }); diff --git a/docs/architecture/alpha-api-contract.md b/docs/architecture/alpha-api-contract.md index 2db26ad..2c662cc 100644 --- a/docs/architecture/alpha-api-contract.md +++ b/docs/architecture/alpha-api-contract.md @@ -139,10 +139,12 @@ Core remains framework-agnostic, renderer-agnostic, and deterministic. #### Auto-Layout compose (opt-in) ```ts -const proposal = createAutoLayoutProposal({ intent, previous? }); +const proposal = createAutoLayoutProposal({ intent, previous?, automaticItemOrder? }); const result = solveLayout({ intent: proposal.effectiveIntent, previous? }); ``` +`automaticItemOrder` (optional, DND-4.2) — advisory processing order for automatic items only. When omitted, Stage B/C use declaration order (Phase 3 default). Core normalizes defensively; unknown, duplicate, and source ids are ignored. + Auto-Layout is **opt-in**. Calling `solveLayout` alone is unchanged. Available on published npm `@alpha` (`0.1.0-alpha.1`). Alpha breaking-change policy above is unchanged. ### Internal to Core (not public) diff --git a/docs/architecture/deterministic-planner.md b/docs/architecture/deterministic-planner.md new file mode 100644 index 0000000..9da569b --- /dev/null +++ b/docs/architecture/deterministic-planner.md @@ -0,0 +1,164 @@ +# Deterministic Intelligence Planner (DND-4.2) + +**Status:** Private / experimental implementation (Stage B) +**Package:** `@dndgem/intelligence` (`private: true`, workspace only) +**Related:** [layout-intelligence-contract.md](./layout-intelligence-contract.md), [ADR-0018](../adr/ADR-0018-layout-intelligence-boundary.md), [auto-layout-contract.md](./auto-layout-contract.md) + +--- + +## Purpose + +DND-4.2 introduces the first production planner code for Phase 4 as a **deterministic advisory layer**. The planner proposes automatic-item processing order only. DnDGem Core remains the sole authority for geometry, validity, scoring, and final resolution. + +```text +PlanningSnapshot + ↓ +createDeterministicPlanningProposal + ↓ +PlanningProposal (advisory) + ↓ +normalizePlanningProposal + ↓ +createAutoLayoutProposal({ automaticItemOrder? }) + ↓ +solveLayout + ↓ +evaluateLayout + ↓ +ResolvedLayout +``` + +This sprint is **headless / caller-composed**. Framework adapters and session hot paths are not wired to the planner in DND-4.2 (deferred to DND-4.3). + +--- + +## Private package status + +`@dndgem/intelligence` is approved only as a **private workspace package** for DND-4.2. It is: + +- not published to npm +- not part of the six-package Changesets fixed group +- not a supported public Alpha API +- not depended on by `@dndgem/dom` or framework adapters in this sprint + +Public API review for intelligence belongs to DND-4.3 / DND-4.5. + +--- + +## Planner input (`PlanningSnapshot`) + +Minimum internal snapshot (not frozen public API): + +| Field | Role | +| ------------- | ------------------------------------------------- | +| `intent` | Core `LayoutIntent` (items, constraints, source) | +| `previous?` | Stability-only prior layout (never Source Intent) | +| `prominence?` | Advisory weights keyed by item id | + +No DOM types, framework types, groups, regions, layout-goal DSL, or provider metadata in DND-4.2. + +--- + +## Prominence heuristic + +The sole DND-4.2 planner strategy ranks **automatic items only** (items without Source Intent placement): + +```text +prominence DESC +→ declaration index ASC +→ itemId ASC +``` + +Behavior: + +- missing prominence → `0` +- non-finite prominence → `0` +- unknown prominence ids → ignored +- Source Intent items → excluded from automatic order +- equal prominence → declaration order preserved + +The planner does **not** call `solveLayout` or `evaluateLayout` and does **not** choose layouts by score. + +--- + +## Planner output (`PlanningProposal`) + +Minimum advisory output: + +```typescript +interface PlanningProposal { + readonly automaticItemOrder: readonly string[]; +} +``` + +No geometry, validity states, solver scores, confidence, or natural-language explanations in DND-4.2. + +--- + +## Normalization (trust boundary) + +Planner output is advisory/untrusted. `normalizePlanningProposal` / Core defensive normalization ensure `automaticItemOrder` cannot corrupt Auto-Layout: + +1. keep valid automatic ids in proposed order (first wins on duplicates) +2. discard unknown, source-intent, and duplicate ids +3. append omitted automatic ids in declaration order +4. fall back entirely to declaration order when the proposal is unusable + +The normalized result is a complete deterministic permutation of automatic item ids. + +--- + +## Core Auto-Layout extension + +`AutoLayoutProposalInput` accepts an optional: + +```typescript +automaticItemOrder?: readonly string[]; +``` + +When omitted, Auto-Layout behavior is **identical to Phase 3** (declaration order for Stage B/C automatic processing). + +When provided, Stage B (previous retention) and Stage C (first-fit reflow) iterate automatic items in the normalized order. Stage A Source occupancy, geometry probes, sizing, hard constraints, provenance, and solver semantics are unchanged. + +Core does not import `@dndgem/intelligence` and applies its own defensive normalization when consumers pass `automaticItemOrder` directly. + +--- + +## Provenance + +Planner guidance is **not** a placement origin. Existing origins remain: + +- `source` — Source Intent +- `generated` — Auto-Layout automatic placement + +Intelligence does not mutate `intent.desiredPlacements` or promote generated placements into Source Intent. + +--- + +## Determinism and lifecycle + +- Same snapshot → same proposal (no randomness, timestamps, or environment dependence) +- No network, AI SDK, or provider dependencies in DND-4.2 +- No model inference in pointermove, drag preview, rAF, ResizeObserver, or every solve call + +--- + +## Accessibility + +Planner ordering affects **visual placement opportunity** for automatic items only. It does not reorder DOM nodes or alter reading order, focus order, keyboard semantics, or ARIA semantics. + +--- + +## Limitations (DND-4.2) + +- Single heuristic only (prominence-weighted order) +- No grouping, regions, affinity, or layout-goal DSL +- No framework/session integration (DND-4.3) +- No model-based planning (DND-4.4) +- No public npm surface for `@dndgem/intelligence` + +--- + +## Fallback + +When planner output is missing, malformed, or unusable, Auto-Layout falls back to Phase 3 declaration order. Deterministic DnDGem continues to function with intelligence absent. diff --git a/docs/architecture/package-boundaries.md b/docs/architecture/package-boundaries.md index c58998d..cc76475 100644 --- a/docs/architecture/package-boundaries.md +++ b/docs/architecture/package-boundaries.md @@ -5,14 +5,16 @@ JS/DOM topology (DND-FX.1 / [ADR-0016](../adr/ADR-0016-framework-package-topology.md)): ```text +@dndgem/core ← @dndgem/intelligence (private, optional — DND-4.2) @dndgem/core ← @dndgem/dom ← sibling adapters (react, vue, angular, svelte) ``` -| Package | May depend on | Must not depend on | -| -------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -| `@dndgem/core` | nothing DnDGem-specific / no DOM | `dom`, adapters, browser APIs, UI frameworks, dnd-kit, AI | -| `@dndgem/dom` | `@dndgem/core`; browser/DOM APIs; `@dnd-kit/dom` (internal) | adapters, React/Vue/Angular/Svelte | -| JS/DOM adapter | `@dndgem/dom`; `@dndgem/core` when public types require it; UI framework as **peerDependency** | other adapters; `@dnd-kit/*` | +| Package | May depend on | Must not depend on | +| ---------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `@dndgem/core` | nothing DnDGem-specific / no DOM | `dom`, adapters, intelligence, browser APIs, UI frameworks, dnd-kit, AI | +| `@dndgem/intelligence` | `@dndgem/core` only (private workspace package; not published in DND-4.2) | `dom`, adapters, UI frameworks, dnd-kit, AI | +| `@dndgem/dom` | `@dndgem/core`; browser/DOM APIs; `@dnd-kit/dom` (internal) | adapters, intelligence, React/Vue/Angular/Svelte | +| JS/DOM adapter | `@dndgem/dom`; `@dndgem/core` when public types require it; UI framework as **peerDependency** | other adapters; intelligence; `@dnd-kit/*` | Current adapters: `@dndgem/react`, `@dndgem/vue`, `@dndgem/angular`, and `@dndgem/svelte` are **published** on `@alpha` (`0.1.0-alpha.3`). @@ -20,7 +22,7 @@ Meta-framework fixtures (`apps/compat-next`, `apps/compat-nuxt`, `apps/compat-sv Forbidden package names: `@dndgem/framework-core`, `@dndgem/vanilla`, `@dndgem/next`, `@dndgem/nuxt`, `@dndgem/sveltekit`, `@dndgem/flutter`, `@dndgem/ai`. -Provisional future name `@dndgem/intelligence` is **not** an approved public API and **must not** be created until an authorized Phase 4 implementation sprint (expected DND-4.2 / DND-4.3) explicitly requires it. Preferred direction: optional intelligence layer depending on Core ([ADR-0018](../adr/ADR-0018-layout-intelligence-boundary.md)); not inside `@dndgem/core` by default. Framework adapters must not become intelligence extension points. +`@dndgem/intelligence` exists as a **private** optional workspace layer (DND-4.2 Stage B). It is not published, not part of the Alpha public surface, and not depended on by `@dndgem/dom` or framework adapters in this sprint. Public API review belongs to DND-4.3 / DND-4.5. Preferred direction: optional intelligence layer depending on Core ([ADR-0018](../adr/ADR-0018-layout-intelligence-boundary.md)); not inside `@dndgem/core` by default. Framework adapters must not become intelligence extension points. Allowlist: `scripts/package-topology.mjs`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 5b2862a..13586a4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,14 +2,14 @@ ## Phase status -| Phase | Name | Status | -| ------- | ------------------------------- | ---------------------------------------------------------------- | -| Phase 0 | Product & Architecture Baseline | **CLOSED** | -| Phase 1 | Technical MVP | **CLOSED** (DND-1.1 → DND-1.8) | -| Phase 2 | Public Alpha Readiness | **PASS** — Public Alpha **LIVE** | -| Phase 3 | Adaptive Auto-Layout | **COMPLETE / RELEASED** (`0.1.0-alpha.1`) | -| — | Framework Expansion Gate | **COMPLETE** (DND-FX.1–FX.6; valid public Alpha `0.1.0-alpha.3`) | -| Phase 4 | AI-Assisted Layout Intelligence | **ACTIVE** — DND-4.1 contract COMPLETE; DND-4.2+ not started | +| Phase | Name | Status | +| ------- | ------------------------------- | ----------------------------------------------------------------------- | +| Phase 0 | Product & Architecture Baseline | **CLOSED** | +| Phase 1 | Technical MVP | **CLOSED** (DND-1.1 → DND-1.8) | +| Phase 2 | Public Alpha Readiness | **PASS** — Public Alpha **LIVE** | +| Phase 3 | Adaptive Auto-Layout | **COMPLETE / RELEASED** (`0.1.0-alpha.1`) | +| — | Framework Expansion Gate | **COMPLETE** (DND-FX.1–FX.6; valid public Alpha `0.1.0-alpha.3`) | +| Phase 4 | AI-Assisted Layout Intelligence | **ACTIVE** — DND-4.1 contract COMPLETE; **DND-4.2 Stage B in progress** | Phase 0 is **CLOSED — GO TO TECHNICAL MVP** (historical). @@ -443,7 +443,7 @@ Meta-frameworks (Next.js, Nuxt, SvelteKit) are **compatibility environments**, n Planning audit: **PASSED WITH ARCHITECTURAL REFINEMENTS** — see [phase-4-planning-audit.md](./architecture/phase-4-planning-audit.md). -**Entry gate:** READY (Phase 3 COMPLETE; Framework Expansion COMPLETE; public Alpha `0.1.0-alpha.3`). **Sprint map:** APPROVED (5 sprints). **Status:** DND-4.1 **COMPLETE** (contract); DND-4.2–DND-4.5 not started. +**Entry gate:** READY (Phase 3 COMPLETE; Framework Expansion COMPLETE; public Alpha `0.1.0-alpha.3`). **Sprint map:** APPROVED (5 sprints). **Status:** DND-4.1 **COMPLETE** (contract); **DND-4.2 Stage B in progress**; DND-4.3–DND-4.5 not started. Primary objective: optional higher-level structural/semantic/application planning **without** replacing deterministic solving. @@ -483,7 +483,7 @@ Phase 4 Planning Audit PASSED WITH REFINEMENTS ↓ DND-4.1 Layout Intelligence Contract & Architecture COMPLETE (contract) ↓ -DND-4.2 Deterministic Intelligence Planner not started +DND-4.2 Deterministic Intelligence Planner **ACTIVE (Stage B)** ↓ DND-4.3 Planner Contract & Optional Integration not started ↓ @@ -515,7 +515,8 @@ DND-4.5 Phase 4 Validation & Alpha Gate not started - **Objective:** Bounded deterministic/heuristic planner that emits Planning Proposals only. - **Out of scope:** Remote model providers; solver semantic change; default-on intelligence. - **Dependencies:** DND-4.1. -- **Status:** Not started +- **Status:** **ACTIVE (Stage B implementation)** — private `@dndgem/intelligence` workspace package; prominence heuristic; optional Core `automaticItemOrder`; not complete until final audit gate. +- **Reference:** [deterministic-planner.md](./architecture/deterministic-planner.md) ### DND-4.3 — Planner Contract & Optional Integration diff --git a/eslint.config.js b/eslint.config.js index faf50ac..9709905 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -75,6 +75,11 @@ export default tseslint.config( message: '@dndgem/core must remain renderer-agnostic and must not import @dndgem/dom.', }, + { + name: '@dndgem/intelligence', + message: + '@dndgem/core must not depend on @dndgem/intelligence (dependency direction is intelligence → core).', + }, ...adapterPackagePaths(), ...FRAMEWORK_RUNTIMES.map((runtime) => ({ name: runtime.name, @@ -86,6 +91,8 @@ export default tseslint.config( { group: [ '@dndgem/dom/*', + '@dndgem/intelligence', + '@dndgem/intelligence/*', '@dndgem/react/*', '@dndgem/vue/*', '@dndgem/angular/*', @@ -156,6 +163,41 @@ export default tseslint.config( ], }, })), + { + files: ['packages/intelligence/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@dndgem/dom', + message: '@dndgem/intelligence must depend on Core only.', + }, + ...adapterPackagePaths(), + ...FRAMEWORK_RUNTIMES.map((runtime) => ({ + name: runtime.name, + message: `@dndgem/intelligence ${runtime.message}`, + })), + ...dndKitPaths('@dndgem/intelligence'), + ], + patterns: [ + { + group: [ + '@dndgem/dom/*', + '@dndgem/react/*', + '@dndgem/vue/*', + '@dndgem/angular/*', + '@dndgem/svelte/*', + '@dnd-kit/*', + ], + message: 'Forbidden import for @dndgem/intelligence.', + }, + ], + }, + ], + }, + }, { files: ['apps/**/*.{ts,tsx}', 'examples/**/*.{ts,tsx}'], languageOptions: { diff --git a/package.json b/package.json index d5fd296..c7f1396 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "bench": "pnpm bench:core", "bench:core": "pnpm --filter @dndgem/core build && pnpm bench:core:check && pnpm bench:core:timing && pnpm bench:core:stats", "bench:core:check": "vitest run --config benchmarks/vitest.config.ts", - "bench:core:semantics": "vitest run --config benchmarks/vitest.config.ts core/semantic.test.ts core/stats.unit.test.ts core/auto-layout.semantic.test.ts", + "bench:core:semantics": "vitest run --config benchmarks/vitest.config.ts core/semantic.test.ts core/stats.unit.test.ts core/auto-layout.semantic.test.ts core/intelligence-planner.semantic.test.ts", "bench:core:timing": "vitest bench --config benchmarks/vitest.bench.config.ts", "bench:core:stats": "vitest run --config benchmarks/vitest.config.ts core/stats.test.ts", "changeset": "changeset", diff --git a/packages/core/src/auto-layout.ts b/packages/core/src/auto-layout.ts index 68e0b44..11256f8 100644 --- a/packages/core/src/auto-layout.ts +++ b/packages/core/src/auto-layout.ts @@ -1,3 +1,4 @@ +import { automaticItemsInOrder, normalizeAutomaticItemOrder } from './automatic-item-order.js'; import { DomainError } from './errors.js'; import { createRect, type Rect, type Size } from './geometry.js'; import { itemIdToString } from './identity.js'; @@ -35,6 +36,12 @@ export interface AutoLayoutProposalInput { * Callers must not treat this as provenance promotion. */ readonly previous?: ResolvedLayout; + /** + * Optional advisory processing order for automatic items (DND-4.2). + * When omitted, declaration order is used (Phase 3 default). + * Core normalizes defensively; unknown, duplicate, and source ids are ignored. + */ + readonly automaticItemOrder?: readonly string[]; } /** @@ -211,7 +218,9 @@ function commitGenerated( * Does not mutate the caller's intent or previous layout. * Does not declare VALID/DEGRADED/INVALID. Does not replace `solveLayout`. * - * Ordering: `LayoutIntent.items` declaration order (stable; no public priority). + * Ordering: `LayoutIntent.items` declaration order by default. Optional + * {@link AutoLayoutProposalInput.automaticItemOrder} may override automatic-item + * processing order only (Stage B/C); source items remain first in occupancy. * Sizing: reuses {@link resolveItemSize} (preferred / useful / minimal). * Retention: previous x/y is a stability preference; current width/height are * authoritative. Size change alone does not force position change. @@ -229,6 +238,8 @@ export function createAutoLayoutProposal(input: AutoLayoutProposalInput): AutoLa const space = intent.space; const sourcePlacements = intent.desiredPlacements; const previousPlacements = input.previous?.placements; + const automaticOrder = normalizeAutomaticItemOrder(intent, input.automaticItemOrder); + const automaticItems = automaticItemsInOrder(intent, automaticOrder); const effectivePlacements: Record = {}; const origins: Record = {}; @@ -252,12 +263,12 @@ export function createAutoLayoutProposal(input: AutoLayoutProposalInput): AutoLa } } - // Stage B — retain previous x/y with current size for automatic items (declaration order). + // Stage B — retain previous x/y with current size for automatic items (normalized order). // Retention is a preference, not a pin. Origin remains `generated` — never promoted to source. // Current sizing is authoritative; previous width/height are not copied. // No automatic compaction: free space does not force first-fit relocation. if (previousPlacements !== undefined) { - for (const item of intent.items) { + for (const item of automaticItems) { const key = itemIdToString(item.id); if (origins[key] === 'source') { continue; @@ -288,8 +299,8 @@ export function createAutoLayoutProposal(input: AutoLayoutProposalInput): AutoLa } // Stage C — deterministic reflow / first placement for remaining automatic items. - // Previously unplaced items are retried here in declaration order (no starvation priority). - for (const item of intent.items) { + // Previously unplaced items are retried here in normalized automatic order. + for (const item of automaticItems) { const key = itemIdToString(item.id); if (origins[key] !== undefined) { continue; diff --git a/packages/core/src/automatic-item-order.ts b/packages/core/src/automatic-item-order.ts new file mode 100644 index 0000000..10e8190 --- /dev/null +++ b/packages/core/src/automatic-item-order.ts @@ -0,0 +1,96 @@ +import { itemIdToString } from './identity.js'; +import type { LayoutIntent } from './intent.js'; +import type { LayoutItem } from './item.js'; + +/** + * Automatic item ids in declaration order (items without Source Intent placement). + * INTERNAL — defensive helper for Auto-Layout ordering. + */ +export function listAutomaticItemIds(intent: LayoutIntent): readonly string[] { + const sourcePlacements = intent.desiredPlacements; + const ids: string[] = []; + for (const item of intent.items) { + const key = itemIdToString(item.id); + if (sourcePlacements?.[key] === undefined) { + ids.push(key); + } + } + return ids; +} + +function isSourceIntentId(intent: LayoutIntent, itemId: string): boolean { + return intent.desiredPlacements?.[itemId] !== undefined; +} + +/** + * Fail-closed normalization for optional automatic-item processing order. + * INTERNAL — mirrors intelligence trust boundary without importing intelligence. + */ +export function normalizeAutomaticItemOrder( + intent: LayoutIntent, + proposedOrder?: readonly string[], +): readonly string[] { + const declarationOrder = listAutomaticItemIds(intent); + if (declarationOrder.length === 0) { + return declarationOrder; + } + + if (proposedOrder === undefined || proposedOrder.length === 0) { + return declarationOrder; + } + + const declarationSet = new Set(declarationOrder); + const seen = new Set(); + const normalized: string[] = []; + let validFromProposal = 0; + + for (const rawId of proposedOrder) { + if (typeof rawId !== 'string' || rawId.length === 0) { + continue; + } + if (isSourceIntentId(intent, rawId)) { + continue; + } + if (!declarationSet.has(rawId)) { + continue; + } + if (seen.has(rawId)) { + continue; + } + seen.add(rawId); + normalized.push(rawId); + validFromProposal += 1; + } + + if (validFromProposal === 0) { + return declarationOrder; + } + + for (const id of declarationOrder) { + if (!seen.has(id)) { + normalized.push(id); + } + } + + return Object.freeze([...normalized]); +} + +/** Resolve automatic items in the given id order; skips unknown ids deterministically. */ +export function automaticItemsInOrder( + intent: LayoutIntent, + order: readonly string[], +): readonly LayoutItem[] { + const byId = new Map(); + for (const item of intent.items) { + byId.set(itemIdToString(item.id), item); + } + + const resolved: LayoutItem[] = []; + for (const id of order) { + const item = byId.get(id); + if (item !== undefined && !isSourceIntentId(intent, id)) { + resolved.push(item); + } + } + return resolved; +} diff --git a/packages/core/tests/automatic-item-order.test.ts b/packages/core/tests/automatic-item-order.test.ts new file mode 100644 index 0000000..f8462e3 --- /dev/null +++ b/packages/core/tests/automatic-item-order.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest'; +import { + createAutoLayoutProposal, + createContentConstraints, + createLayoutIntent, + createLayoutItem, + solveLayout, +} from '../src/index.js'; +import { listAutomaticItemIds, normalizeAutomaticItemOrder } from '../src/automatic-item-order.js'; + +function intentWith( + space: { width: number; height: number }, + items: Array<{ + id: string; + constraints?: Parameters[0]; + }>, + desiredPlacements?: Record, +) { + return createLayoutIntent({ + space, + items: items.map((item) => + createLayoutItem({ + id: item.id, + constraints: createContentConstraints(item.constraints ?? {}), + }), + ), + desiredPlacements, + }); +} + +describe('normalizeAutomaticItemOrder (Core defensive)', () => { + const intent = intentWith({ width: 400, height: 300 }, [{ id: 'a' }, { id: 'b' }]); + + it('falls back to declaration order for unusable proposals', () => { + expect(normalizeAutomaticItemOrder(intent, ['unknown'])).toEqual(['a', 'b']); + }); + + it('lists automatic ids in declaration order', () => { + const hybrid = intentWith({ width: 400, height: 300 }, [{ id: 'source' }, { id: 'a' }], { + source: { x: 0, y: 0, width: 10, height: 10 }, + }); + expect(listAutomaticItemIds(hybrid)).toEqual(['a']); + }); +}); + +describe('createAutoLayoutProposal — automaticItemOrder', () => { + const baseIntent = () => + intentWith({ width: 200, height: 100 }, [ + { + id: 'blocker', + constraints: { preferredWidth: 120, preferredHeight: 100, minWidth: 40 }, + }, + { + id: 'first', + constraints: { preferredWidth: 90, preferredHeight: 100, minWidth: 40 }, + }, + { + id: 'second', + constraints: { preferredWidth: 90, preferredHeight: 100, minWidth: 40 }, + }, + ]); + + it('default path without automaticItemOrder matches Phase 3 declaration-order behavior', () => { + const intent = baseIntent(); + const baseline = createAutoLayoutProposal({ intent }); + const explicitDeclaration = createAutoLayoutProposal({ + intent, + automaticItemOrder: ['blocker', 'first', 'second'], + }); + expect(explicitDeclaration).toEqual(baseline); + expect(baseline.unplacedItemIds).toEqual(['first', 'second']); + }); + + it('respects a valid automaticItemOrder', () => { + const intent = baseIntent(); + const guided = createAutoLayoutProposal({ + intent, + automaticItemOrder: ['first', 'second', 'blocker'], + }); + expect(guided.unplacedItemIds).toEqual(['blocker']); + expect(guided.placementOrigins.first).toBe('generated'); + expect(guided.placementOrigins.second).toBe('generated'); + }); + + it('handles partial order safely by appending omitted ids', () => { + const intent = baseIntent(); + const partial = createAutoLayoutProposal({ + intent, + automaticItemOrder: ['second'], + }); + const guided = createAutoLayoutProposal({ + intent, + automaticItemOrder: ['second', 'blocker', 'first'], + }); + expect(partial.unplacedItemIds).toEqual(['blocker']); + expect(guided.unplacedItemIds).toEqual(['blocker']); + }); + + it('ignores unknown and source ids in automaticItemOrder', () => { + const intent = intentWith( + { width: 200, height: 100 }, + [ + { + id: 'pinned', + constraints: { preferredWidth: 100, preferredHeight: 100, minWidth: 40 }, + }, + { + id: 'auto', + constraints: { preferredWidth: 90, preferredHeight: 90, minWidth: 40 }, + }, + ], + { pinned: { x: 0, y: 0, width: 100, height: 100 } }, + ); + const proposal = createAutoLayoutProposal({ + intent, + automaticItemOrder: ['pinned', 'ghost', 'auto', 'auto'], + }); + expect(proposal.placementOrigins.pinned).toBe('source'); + expect(proposal.generatedPlacements.auto).toEqual({ x: 100, y: 0, width: 90, height: 90 }); + }); + + it('does not mutate caller intent or order array', () => { + const intent = baseIntent(); + const order = Object.freeze(['first', 'second', 'blocker']); + createAutoLayoutProposal({ intent, automaticItemOrder: order }); + expect(order).toEqual(['first', 'second', 'blocker']); + expect(intent.items).toHaveLength(3); + }); +}); + +describe('createAutoLayoutProposal — provenance with automaticItemOrder', () => { + it('never introduces planner/intelligence placement origins', () => { + const intent = intentWith({ width: 200, height: 100 }, [ + { id: 'a', constraints: { preferredWidth: 90, preferredHeight: 90, minWidth: 40 } }, + { id: 'b', constraints: { preferredWidth: 90, preferredHeight: 90, minWidth: 40 } }, + ]); + const proposal = createAutoLayoutProposal({ + intent, + automaticItemOrder: ['b', 'a'], + }); + for (const origin of Object.values(proposal.placementOrigins)) { + expect(origin === 'source' || origin === 'generated').toBe(true); + } + }); + + it('does not promote generated placements into Source Intent input', () => { + const intent = intentWith({ width: 400, height: 300 }, [ + { id: 'a', constraints: { preferredWidth: 80, preferredHeight: 40, minWidth: 20 } }, + { id: 'b', constraints: { preferredWidth: 80, preferredHeight: 40, minWidth: 20 } }, + ]); + const originalDesired = intent.desiredPlacements; + const proposal = createAutoLayoutProposal({ + intent, + automaticItemOrder: ['b', 'a'], + }); + expect(intent.desiredPlacements).toBe(originalDesired); + expect(proposal.effectiveIntent.desiredPlacements?.a).toBeDefined(); + }); +}); + +describe('createAutoLayoutProposal — automaticItemOrder determinism', () => { + it('repeated runs with the same input stay identical (50×)', () => { + const intent = intentWith( + { width: 250, height: 120 }, + [ + { id: 'pinned', constraints: { preferredWidth: 100, preferredHeight: 120, minWidth: 40 } }, + { id: 'a', constraints: { preferredWidth: 100, preferredHeight: 70, minWidth: 40 } }, + { id: 'b', constraints: { preferredWidth: 100, preferredHeight: 60, minWidth: 40 } }, + { id: 'c', constraints: { preferredWidth: 100, preferredHeight: 50, minWidth: 40 } }, + ], + { pinned: { x: 0, y: 0, width: 100, height: 120 } }, + ); + + const input = { intent, automaticItemOrder: ['b', 'c', 'a'] as const }; + const firstProposal = createAutoLayoutProposal(input); + const firstSolve = solveLayout({ intent: firstProposal.effectiveIntent }); + + for (let i = 0; i < 50; i += 1) { + const proposal = createAutoLayoutProposal(input); + expect(proposal).toEqual(firstProposal); + expect(solveLayout({ intent: proposal.effectiveIntent })).toEqual(firstSolve); + } + }); +}); diff --git a/packages/intelligence/package.json b/packages/intelligence/package.json new file mode 100644 index 0000000..d2d5bf1 --- /dev/null +++ b/packages/intelligence/package.json @@ -0,0 +1,30 @@ +{ + "name": "@dndgem/intelligence", + "version": "0.0.0", + "private": true, + "description": "Private deterministic layout intelligence planner for DnDGem (DND-4.2 experimental).", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "lint": "eslint src --max-warnings=0", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@dndgem/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.9.3", + "vitest": "^3.2.7" + } +} diff --git a/packages/intelligence/src/index.ts b/packages/intelligence/src/index.ts new file mode 100644 index 0000000..e6112e2 --- /dev/null +++ b/packages/intelligence/src/index.ts @@ -0,0 +1,16 @@ +/** + * @dndgem/intelligence — private deterministic layout planner (DND-4.2). + * + * Experimental workspace package. Not published. Not part of the Alpha public surface. + * Framework adapters do not depend on this package in DND-4.2. + */ + +export { createDeterministicPlanningProposal, normalizePlanningProposal } from './planner.js'; + +export { + listAutomaticItemIds, + normalizeAutomaticItemOrder, + rankAutomaticItemsByProminence, +} from './normalize.js'; + +export type { PlanningProposal, PlanningSnapshot } from './types.js'; diff --git a/packages/intelligence/src/normalize.ts b/packages/intelligence/src/normalize.ts new file mode 100644 index 0000000..6d7389b --- /dev/null +++ b/packages/intelligence/src/normalize.ts @@ -0,0 +1,125 @@ +import { itemIdToString, type LayoutIntent } from '@dndgem/core'; + +/** + * Automatic item ids in declaration order (items without Source Intent placement). + */ +export function listAutomaticItemIds(intent: LayoutIntent): readonly string[] { + const sourcePlacements = intent.desiredPlacements; + const ids: string[] = []; + for (const item of intent.items) { + const key = itemIdToString(item.id); + if (sourcePlacements?.[key] === undefined) { + ids.push(key); + } + } + return ids; +} + +function isSourceIntentId(intent: LayoutIntent, itemId: string): boolean { + return intent.desiredPlacements?.[itemId] !== undefined; +} + +/** + * Fail-closed normalization for advisory automatic-item processing order. + * + * Returns a complete deterministic permutation of automatic item ids: + * 1. keep valid automatic ids in proposed order (first occurrence wins); + * 2. discard unknown, source-intent, and duplicate ids; + * 3. append omitted automatic ids in declaration order; + * 4. fall back entirely to declaration order when the proposal is unusable. + * + * Does not mutate caller inputs. + */ +export function normalizeAutomaticItemOrder( + intent: LayoutIntent, + proposedOrder?: readonly string[], +): readonly string[] { + const declarationOrder = listAutomaticItemIds(intent); + if (declarationOrder.length === 0) { + return declarationOrder; + } + + if (proposedOrder === undefined || proposedOrder.length === 0) { + return declarationOrder; + } + + const declarationSet = new Set(declarationOrder); + const seen = new Set(); + const normalized: string[] = []; + let validFromProposal = 0; + + for (const rawId of proposedOrder) { + if (typeof rawId !== 'string' || rawId.length === 0) { + continue; + } + if (isSourceIntentId(intent, rawId)) { + continue; + } + if (!declarationSet.has(rawId)) { + continue; + } + if (seen.has(rawId)) { + continue; + } + seen.add(rawId); + normalized.push(rawId); + validFromProposal += 1; + } + + if (validFromProposal === 0) { + return declarationOrder; + } + + for (const id of declarationOrder) { + if (!seen.has(id)) { + normalized.push(id); + } + } + + return Object.freeze([...normalized]); +} + +function sanitizeProminence(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + return value; +} + +/** + * Deterministic prominence-weighted automatic-item order. + * + * Total order: prominence DESC → declaration index ASC → itemId ASC. + */ +export function rankAutomaticItemsByProminence( + intent: LayoutIntent, + prominence?: Readonly>, +): readonly string[] { + const automaticIds = listAutomaticItemIds(intent); + if (automaticIds.length === 0) { + return automaticIds; + } + + const declarationIndex = new Map(); + for (let index = 0; index < automaticIds.length; index += 1) { + declarationIndex.set(automaticIds[index]!, index); + } + + const ranked = [...automaticIds].sort((leftId, rightId) => { + const leftProminence = sanitizeProminence(prominence?.[leftId]); + const rightProminence = sanitizeProminence(prominence?.[rightId]); + if (leftProminence !== rightProminence) { + return rightProminence - leftProminence; + } + + const leftIndex = declarationIndex.get(leftId) ?? 0; + const rightIndex = declarationIndex.get(rightId) ?? 0; + if (leftIndex !== rightIndex) { + return leftIndex - rightIndex; + } + + return leftId.localeCompare(rightId); + }); + + return Object.freeze(ranked); +} diff --git a/packages/intelligence/src/planner.ts b/packages/intelligence/src/planner.ts new file mode 100644 index 0000000..3c1bcff --- /dev/null +++ b/packages/intelligence/src/planner.ts @@ -0,0 +1,35 @@ +import type { PlanningProposal, PlanningSnapshot } from './types.js'; +import { normalizeAutomaticItemOrder, rankAutomaticItemsByProminence } from './normalize.js'; + +/** + * Deterministic local planner (DND-4.2). + * + * Emits advisory automatic-item processing order only. + * Does not call solveLayout/evaluateLayout or compute geometry. + * Does not mutate caller inputs. + */ +export function createDeterministicPlanningProposal(snapshot: PlanningSnapshot): PlanningProposal { + const automaticItemOrder = rankAutomaticItemsByProminence(snapshot.intent, snapshot.prominence); + + return Object.freeze({ + automaticItemOrder: Object.freeze([...automaticItemOrder]), + }); +} + +/** + * Normalize an advisory PlanningProposal against a snapshot. + * Reusable trust-boundary for DND-4.3 async planners. + */ +export function normalizePlanningProposal( + snapshot: PlanningSnapshot, + proposal: PlanningProposal, +): PlanningProposal { + const automaticItemOrder = normalizeAutomaticItemOrder( + snapshot.intent, + proposal.automaticItemOrder, + ); + + return Object.freeze({ + automaticItemOrder: Object.freeze([...automaticItemOrder]), + }); +} diff --git a/packages/intelligence/src/types.ts b/packages/intelligence/src/types.ts new file mode 100644 index 0000000..b51d3fe --- /dev/null +++ b/packages/intelligence/src/types.ts @@ -0,0 +1,28 @@ +import type { LayoutIntent, ResolvedLayout } from '@dndgem/core'; + +/** + * Minimum structural input for the DND-4.2 deterministic planner. + * INTERNAL — not a frozen public Alpha API. + */ +export interface PlanningSnapshot { + readonly intent: LayoutIntent; + /** + * Optional prior resolved layout (stability signal only). + * The planner does not treat this as Source Intent or placement origin. + */ + readonly previous?: ResolvedLayout; + /** + * Advisory prominence weights keyed by item id. + * Higher values are processed earlier among automatic items. + * Unknown ids are ignored; missing entries default to 0. + */ + readonly prominence?: Readonly>; +} + +/** + * Minimum advisory planner output for DND-4.2. + * INTERNAL — not a frozen public Alpha API. + */ +export interface PlanningProposal { + readonly automaticItemOrder: readonly string[]; +} diff --git a/packages/intelligence/tests/boundary.test.ts b/packages/intelligence/tests/boundary.test.ts new file mode 100644 index 0000000..efaa28a --- /dev/null +++ b/packages/intelligence/tests/boundary.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); + +describe('@dndgem/intelligence package boundary', () => { + it('remains private workspace package', () => { + const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')) as { + private?: boolean; + name: string; + }; + expect(pkg.name).toBe('@dndgem/intelligence'); + expect(pkg.private).toBe(true); + }); + + it('depends on Core only in production dependencies', () => { + const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')) as { + dependencies?: Record; + }; + expect(Object.keys(pkg.dependencies ?? {})).toEqual(['@dndgem/core']); + }); + + it('production sources do not import DOM or framework packages', () => { + const files = ['src/index.ts', 'src/planner.ts', 'src/normalize.ts', 'src/types.ts']; + const forbidden = [ + '@dndgem/dom', + '@dndgem/react', + '@dndgem/vue', + '@dndgem/angular', + '@dndgem/svelte', + '@dnd-kit/', + 'openai', + 'anthropic', + '@anthropic-ai/', + '@google/generative-ai', + ]; + for (const file of files) { + const source = readFileSync(join(pkgRoot, file), 'utf8'); + for (const token of forbidden) { + expect(source.includes(token), `${file} must not reference ${token}`).toBe(false); + } + } + }); +}); diff --git a/packages/intelligence/tests/integration.test.ts b/packages/intelligence/tests/integration.test.ts new file mode 100644 index 0000000..c18fddb --- /dev/null +++ b/packages/intelligence/tests/integration.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest'; +import { + createAutoLayoutProposal, + createContentConstraints, + createLayoutIntent, + createLayoutItem, + solveLayout, +} from '@dndgem/core'; +import { createDeterministicPlanningProposal, normalizePlanningProposal } from '../src/index.js'; + +function intentWith( + space: { width: number; height: number }, + items: Array<{ + id: string; + constraints?: Parameters[0]; + }>, + desiredPlacements?: Record, +) { + return createLayoutIntent({ + space, + items: items.map((item) => + createLayoutItem({ + id: item.id, + constraints: createContentConstraints(item.constraints ?? {}), + }), + ), + desiredPlacements, + }); +} + +describe('DND-4.2 integration pipeline', () => { + it('snapshot → planner → normalize → createAutoLayoutProposal → solveLayout', () => { + const intent = intentWith({ width: 200, height: 100 }, [ + { + id: 'blocker', + constraints: { preferredWidth: 120, preferredHeight: 100, minWidth: 40 }, + }, + { + id: 'target-a', + constraints: { preferredWidth: 90, preferredHeight: 100, minWidth: 40 }, + }, + { + id: 'target-b', + constraints: { preferredWidth: 90, preferredHeight: 100, minWidth: 40 }, + }, + ]); + + const snapshot = { + intent, + prominence: { 'target-a': 10, 'target-b': 5, blocker: 0 }, + }; + + const raw = createDeterministicPlanningProposal(snapshot); + const normalized = normalizePlanningProposal(snapshot, raw); + const proposal = createAutoLayoutProposal({ + intent, + automaticItemOrder: normalized.automaticItemOrder, + }); + const result = solveLayout({ intent: proposal.effectiveIntent }); + + expect(proposal.unplacedItemIds).toEqual(['blocker']); + expect(proposal.placementOrigins['target-a']).toBe('generated'); + expect(proposal.placementOrigins['target-b']).toBe('generated'); + expect(proposal.placementOrigins.blocker).toBeUndefined(); + expect(result.evaluation.state).toMatch(/VALID|DEGRADED/); + }); +}); + +describe('DND-4.2 integration — determinism', () => { + it('repeated pipeline runs produce identical outputs (50×)', () => { + const intent = intentWith( + { width: 250, height: 120 }, + [ + { id: 'pinned', constraints: { preferredWidth: 100, preferredHeight: 120, minWidth: 40 } }, + { id: 'a', constraints: { preferredWidth: 100, preferredHeight: 70, minWidth: 40 } }, + { id: 'b', constraints: { preferredWidth: 100, preferredHeight: 60, minWidth: 40 } }, + { id: 'c', constraints: { preferredWidth: 100, preferredHeight: 50, minWidth: 40 } }, + ], + { pinned: { x: 0, y: 0, width: 100, height: 120 } }, + ); + + const snapshot = { + intent, + prominence: { a: 2, b: 10, c: 5 }, + }; + + const runOnce = () => { + const normalized = normalizePlanningProposal( + snapshot, + createDeterministicPlanningProposal(snapshot), + ); + const proposal = createAutoLayoutProposal({ + intent, + automaticItemOrder: normalized.automaticItemOrder, + }); + const result = solveLayout({ intent: proposal.effectiveIntent }); + return { + order: normalized.automaticItemOrder, + proposal, + result, + }; + }; + + const first = runOnce(); + for (let i = 0; i < 50; i += 1) { + expect(runOnce()).toEqual(first); + } + }); +}); + +describe('DND-4.2 product-value fixtures', () => { + const orderSensitiveIntent = () => + intentWith({ width: 200, height: 100 }, [ + { + id: 'blocker', + constraints: { preferredWidth: 120, preferredHeight: 100, minWidth: 40 }, + }, + { + id: 'target-a', + constraints: { preferredWidth: 90, preferredHeight: 100, minWidth: 40 }, + }, + { + id: 'target-b', + constraints: { preferredWidth: 90, preferredHeight: 100, minWidth: 40 }, + }, + ]); + + it('Fixture A — prominence order places more automatic items than declaration order', () => { + const intent = orderSensitiveIntent(); + const baseline = createAutoLayoutProposal({ intent }); + const snapshot = { + intent, + prominence: { 'target-a': 10, 'target-b': 5, blocker: 0 }, + }; + const guided = createAutoLayoutProposal({ + intent, + automaticItemOrder: normalizePlanningProposal( + snapshot, + createDeterministicPlanningProposal(snapshot), + ).automaticItemOrder, + }); + + expect(baseline.unplacedItemIds).toEqual(['target-a', 'target-b']); + expect(guided.unplacedItemIds).toEqual(['blocker']); + expect(guided.unplacedItemIds.length).toBeLessThan(baseline.unplacedItemIds.length); + }); + + it('Fixture B — prominent item receives the first viable automatic slot', () => { + const intent = intentWith( + { width: 200, height: 100 }, + [ + { + id: 'pinned', + constraints: { preferredWidth: 100, preferredHeight: 100, minWidth: 40 }, + }, + { + id: 'low', + constraints: { preferredWidth: 90, preferredHeight: 90, minWidth: 40 }, + }, + { + id: 'high', + constraints: { preferredWidth: 90, preferredHeight: 90, minWidth: 40 }, + }, + ], + { pinned: { x: 0, y: 0, width: 100, height: 100 } }, + ); + + const baseline = createAutoLayoutProposal({ intent }); + const snapshot = { intent, prominence: { high: 10, low: 0 } }; + const guided = createAutoLayoutProposal({ + intent, + automaticItemOrder: normalizePlanningProposal( + snapshot, + createDeterministicPlanningProposal(snapshot), + ).automaticItemOrder, + }); + + expect(baseline.placementOrigins.high).toBeUndefined(); + expect(baseline.generatedPlacements.high).toBeUndefined(); + expect(baseline.generatedPlacements.low).toBeDefined(); + expect(guided.generatedPlacements.high).toEqual({ x: 100, y: 0, width: 90, height: 90 }); + expect(guided.placementOrigins.high).toBe('generated'); + }); + + it('Fixture C — hybrid source + automatic competition preserves source and provenance', () => { + const intent = intentWith( + { width: 200, height: 100 }, + [ + { + id: 'pinned', + constraints: { preferredWidth: 100, preferredHeight: 100, minWidth: 40 }, + }, + { + id: 'low', + constraints: { preferredWidth: 90, preferredHeight: 90, minWidth: 40 }, + }, + { + id: 'high', + constraints: { preferredWidth: 90, preferredHeight: 90, minWidth: 40 }, + }, + ], + { pinned: { x: 0, y: 0, width: 100, height: 100 } }, + ); + + const snapshot = { intent, prominence: { high: 10, low: 0 } }; + const guided = createAutoLayoutProposal({ + intent, + automaticItemOrder: normalizePlanningProposal( + snapshot, + createDeterministicPlanningProposal(snapshot), + ).automaticItemOrder, + }); + + expect(guided.placementOrigins.pinned).toBe('source'); + expect(guided.effectiveIntent.desiredPlacements?.pinned).toEqual( + intent.desiredPlacements?.pinned, + ); + expect(guided.placementOrigins.high).toBe('generated'); + expect(guided.placementOrigins.low).toBeUndefined(); + expect(Object.values(guided.placementOrigins)).not.toContain('intelligence' as never); + expect(Object.values(guided.placementOrigins)).not.toContain('planner' as never); + }); +}); diff --git a/packages/intelligence/tests/normalize.test.ts b/packages/intelligence/tests/normalize.test.ts new file mode 100644 index 0000000..329687f --- /dev/null +++ b/packages/intelligence/tests/normalize.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { createContentConstraints, createLayoutIntent, createLayoutItem } from '@dndgem/core'; +import { normalizeAutomaticItemOrder } from '../src/normalize.js'; +import { normalizePlanningProposal } from '../src/planner.js'; +import type { PlanningSnapshot } from '../src/types.js'; + +function intentWith( + space: { width: number; height: number }, + items: Array<{ id: string }>, + desiredPlacements?: Record, +) { + return createLayoutIntent({ + space, + items: items.map((item) => + createLayoutItem({ + id: item.id, + constraints: createContentConstraints({ preferredWidth: 80, preferredHeight: 40 }), + }), + ), + desiredPlacements, + }); +} + +describe('normalizeAutomaticItemOrder', () => { + const intent = intentWith({ width: 400, height: 300 }, [{ id: 'a' }, { id: 'b' }, { id: 'c' }]); + + it('preserves a valid full permutation', () => { + expect(normalizeAutomaticItemOrder(intent, ['c', 'a', 'b'])).toEqual(['c', 'a', 'b']); + }); + + it('removes unknown ids and appends omitted automatic ids in declaration order', () => { + expect(normalizeAutomaticItemOrder(intent, ['c', 'ghost', 'a'])).toEqual(['c', 'a', 'b']); + }); + + it('removes source intent ids', () => { + const hybrid = intentWith( + { width: 400, height: 300 }, + [{ id: 'source' }, { id: 'a' }, { id: 'b' }], + { source: { x: 0, y: 0, width: 50, height: 50 } }, + ); + expect(normalizeAutomaticItemOrder(hybrid, ['source', 'b', 'a'])).toEqual(['b', 'a']); + }); + + it('removes duplicates deterministically (first occurrence wins)', () => { + expect(normalizeAutomaticItemOrder(intent, ['b', 'b', 'a', 'c'])).toEqual(['b', 'a', 'c']); + }); + + it('falls back to declaration order for empty proposals', () => { + expect(normalizeAutomaticItemOrder(intent, [])).toEqual(['a', 'b', 'c']); + }); + + it('falls back to declaration order when proposal has no valid automatic ids', () => { + expect(normalizeAutomaticItemOrder(intent, ['ghost', 'source'])).toEqual(['a', 'b', 'c']); + }); + + it('returns declaration order when proposal is omitted', () => { + expect(normalizeAutomaticItemOrder(intent)).toEqual(['a', 'b', 'c']); + }); +}); + +describe('normalizePlanningProposal', () => { + it('wraps normalized order in a PlanningProposal', () => { + const intent = intentWith({ width: 400, height: 300 }, [{ id: 'a' }, { id: 'b' }]); + const snapshot: PlanningSnapshot = { intent }; + const normalized = normalizePlanningProposal(snapshot, { + automaticItemOrder: ['b', 'a'], + }); + expect(normalized.automaticItemOrder).toEqual(['b', 'a']); + }); +}); diff --git a/packages/intelligence/tests/planner.test.ts b/packages/intelligence/tests/planner.test.ts new file mode 100644 index 0000000..b1712a7 --- /dev/null +++ b/packages/intelligence/tests/planner.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { createContentConstraints, createLayoutIntent, createLayoutItem } from '@dndgem/core'; +import { createDeterministicPlanningProposal } from '../src/planner.js'; +import { rankAutomaticItemsByProminence } from '../src/normalize.js'; +import type { PlanningSnapshot } from '../src/types.js'; + +function intentWith( + space: { width: number; height: number }, + items: Array<{ + id: string; + constraints?: Parameters[0]; + }>, + desiredPlacements?: Record, +) { + return createLayoutIntent({ + space, + items: items.map((item) => + createLayoutItem({ + id: item.id, + constraints: createContentConstraints(item.constraints ?? {}), + }), + ), + desiredPlacements, + }); +} + +describe('createDeterministicPlanningProposal', () => { + it('returns declaration order when prominence is absent', () => { + const intent = intentWith({ width: 400, height: 300 }, [{ id: 'a' }, { id: 'b' }, { id: 'c' }]); + const snapshot: PlanningSnapshot = { intent }; + const proposal = createDeterministicPlanningProposal(snapshot); + expect(proposal.automaticItemOrder).toEqual(['a', 'b', 'c']); + }); + + it('ranks higher prominence before lower prominence', () => { + const intent = intentWith({ width: 400, height: 300 }, [ + { id: 'low' }, + { id: 'high' }, + { id: 'mid' }, + ]); + const proposal = createDeterministicPlanningProposal({ + intent, + prominence: { low: 1, mid: 5, high: 10 }, + }); + expect(proposal.automaticItemOrder).toEqual(['high', 'mid', 'low']); + }); + + it('preserves declaration order for equal prominence', () => { + const intent = intentWith({ width: 400, height: 300 }, [{ id: 'a' }, { id: 'b' }, { id: 'c' }]); + const proposal = createDeterministicPlanningProposal({ + intent, + prominence: { a: 1, b: 1, c: 1 }, + }); + expect(proposal.automaticItemOrder).toEqual(['a', 'b', 'c']); + }); + + it('preserves declaration order for equal prominence regardless of item id lex order', () => { + const intent = intentWith({ width: 400, height: 300 }, [{ id: 'z' }, { id: 'a' }, { id: 'm' }]); + const order = rankAutomaticItemsByProminence(intent, { z: 1, a: 1, m: 1 }); + expect(order).toEqual(['z', 'a', 'm']); + }); + + it('ignores unknown prominence ids', () => { + const intent = intentWith({ width: 400, height: 300 }, [{ id: 'a' }, { id: 'b' }]); + const proposal = createDeterministicPlanningProposal({ + intent, + prominence: { ghost: 99, a: 2, b: 1 }, + }); + expect(proposal.automaticItemOrder).toEqual(['a', 'b']); + }); + + it('excludes source intent items from automatic order', () => { + const intent = intentWith( + { width: 400, height: 300 }, + [{ id: 'source' }, { id: 'auto-a' }, { id: 'auto-b' }], + { source: { x: 0, y: 0, width: 50, height: 50 } }, + ); + const proposal = createDeterministicPlanningProposal({ + intent, + prominence: { 'auto-b': 5, 'auto-a': 1 }, + }); + expect(proposal.automaticItemOrder).toEqual(['auto-b', 'auto-a']); + }); + + it('treats non-finite prominence as zero', () => { + const intent = intentWith({ width: 400, height: 300 }, [{ id: 'a' }, { id: 'b' }]); + const proposal = createDeterministicPlanningProposal({ + intent, + prominence: { a: Number.NaN, b: Number.POSITIVE_INFINITY }, + }); + expect(proposal.automaticItemOrder).toEqual(['a', 'b']); + }); + + it('does not mutate snapshot inputs', () => { + const intent = intentWith({ width: 400, height: 300 }, [{ id: 'a' }, { id: 'b' }]); + const prominence = Object.freeze({ a: 2, b: 1 }); + const snapshot = Object.freeze({ intent, prominence }); + createDeterministicPlanningProposal(snapshot); + expect(snapshot.prominence).toEqual({ a: 2, b: 1 }); + expect(intent.items).toHaveLength(2); + }); +}); + +describe('createDeterministicPlanningProposal — determinism', () => { + it('returns identical proposals for identical snapshots', () => { + const intent = intentWith({ width: 320, height: 240 }, [ + { id: 'chart' }, + { id: 'legend' }, + { id: 'controls' }, + ]); + const snapshot: PlanningSnapshot = { + intent, + prominence: { chart: 10, legend: 3, controls: 7, unused: 99 }, + }; + const first = createDeterministicPlanningProposal(snapshot); + for (let i = 0; i < 50; i += 1) { + expect(createDeterministicPlanningProposal(snapshot)).toEqual(first); + } + }); +}); diff --git a/packages/intelligence/tsconfig.build.json b/packages/intelligence/tsconfig.build.json new file mode 100644 index 0000000..3d7027d --- /dev/null +++ b/packages/intelligence/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["tests", "dist"] +} diff --git a/packages/intelligence/tsconfig.json b/packages/intelligence/tsconfig.json new file mode 100644 index 0000000..0d392d3 --- /dev/null +++ b/packages/intelligence/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "noEmit": true + }, + "include": ["src", "tests"] +} diff --git a/packages/intelligence/vitest.config.ts b/packages/intelligence/vitest.config.ts new file mode 100644 index 0000000..e1abadc --- /dev/null +++ b/packages/intelligence/vitest.config.ts @@ -0,0 +1,17 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const root = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + }, + resolve: { + alias: { + '@dndgem/core': path.resolve(root, '../core/dist/index.js'), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ceecc9..8a28829 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,6 +364,19 @@ importers: specifier: ^3.2.7 version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.50.0)(yaml@2.9.0) + packages/intelligence: + dependencies: + '@dndgem/core': + specifier: workspace:* + version: link:../core + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.50.0)(yaml@2.9.0) + packages/react: dependencies: '@dndgem/core': diff --git a/scripts/check-boundaries.mjs b/scripts/check-boundaries.mjs index c1ff5f9..674224e 100644 --- a/scripts/check-boundaries.mjs +++ b/scripts/check-boundaries.mjs @@ -13,6 +13,8 @@ import { FORBIDDEN_PACKAGE_FOLDERS, FRAMEWORK_ADAPTER_FOLDERS, FRAMEWORK_PEER_BY_FOLDER, + INTELLIGENCE_FOLDER, + OPTIONAL_PRIVATE_FOLDERS, existingAdapterFolders, existingPackageFolders, npmNameForFolder, @@ -79,6 +81,7 @@ function assertPublishableMetadata(packageName, pkg) { const adapterNpmNames = FRAMEWORK_ADAPTER_FOLDERS.map((folder) => npmNameForFolder(folder)); const adapterPeerNames = Object.values(FRAMEWORK_PEER_BY_FOLDER); const frameworkRuntimeNames = new Set(['react', 'react-dom', ...adapterPeerNames]); +const intelligenceNpmName = npmNameForFolder(INTELLIGENCE_FOLDER); const core = readPkg(CORE_FOLDER); if (!core) { @@ -89,6 +92,7 @@ if (!core) { core, new Set([ npmNameForFolder(DOM_FOLDER), + intelligenceNpmName, ...adapterNpmNames, ...frameworkRuntimeNames, '@dnd-kit/dom', @@ -112,8 +116,8 @@ if (!dom) { assertNoDeps( DOM_FOLDER, dom, - new Set([...adapterNpmNames, ...frameworkRuntimeNames]), - 'DOM must not depend on framework adapters or UI frameworks', + new Set([...adapterNpmNames, ...frameworkRuntimeNames, intelligenceNpmName]), + 'DOM must not depend on framework adapters, UI frameworks, or intelligence', ); if (!dom.dependencies?.['@dndgem/core']) { errors.push('@dndgem/dom must declare a dependency on @dndgem/core'); @@ -149,6 +153,12 @@ for (const folder of existingAdapterFolders()) { new Set(otherAdapters), 'adapters must not depend on another framework adapter', ); + assertNoDeps( + folder, + pkg, + new Set([intelligenceNpmName]), + 'adapters must not depend on intelligence in DND-4.2', + ); assertNoDeps( folder, pkg, @@ -165,7 +175,44 @@ for (const folder of existingAdapterFolders()) { assertPublishableMetadata(folder, pkg); } -const allowedFolders = new Set([CORE_FOLDER, DOM_FOLDER, ...FRAMEWORK_ADAPTER_FOLDERS]); +const intelligence = readPkg(INTELLIGENCE_FOLDER); +if (packageDirExists(INTELLIGENCE_FOLDER)) { + if (!intelligence) { + errors.push(`Missing package.json for packages/${INTELLIGENCE_FOLDER}`); + } else { + if (intelligence.private !== true) { + errors.push(`packages/${INTELLIGENCE_FOLDER} must remain private during DND-4.2`); + } + if (!intelligence.dependencies?.['@dndgem/core']) { + errors.push(`${intelligenceNpmName} must declare a dependency on @dndgem/core`); + } + assertNoDeps( + INTELLIGENCE_FOLDER, + intelligence, + new Set([ + npmNameForFolder(DOM_FOLDER), + ...adapterNpmNames, + ...frameworkRuntimeNames, + '@dnd-kit/dom', + '@dnd-kit/core', + '@dnd-kit/react', + ]), + 'intelligence must depend on Core only', + ); + for (const dep of Object.keys(allDeps(intelligence))) { + if (dep.startsWith('@dnd-kit/')) { + errors.push(`packages/${INTELLIGENCE_FOLDER} must not depend on "${dep}"`); + } + } + } +} + +const allowedFolders = new Set([ + CORE_FOLDER, + DOM_FOLDER, + ...FRAMEWORK_ADAPTER_FOLDERS, + ...OPTIONAL_PRIVATE_FOLDERS, +]); for (const folder of existingPackageFolders()) { if (FORBIDDEN_PACKAGE_FOLDERS.includes(folder)) { errors.push( @@ -175,7 +222,7 @@ for (const folder of existingPackageFolders()) { } if (!allowedFolders.has(folder)) { errors.push( - `Unexpected package folder packages/${folder} (allowed: core, dom, ${FRAMEWORK_ADAPTER_FOLDERS.join(', ')})`, + `Unexpected package folder packages/${folder} (allowed: core, dom, ${FRAMEWORK_ADAPTER_FOLDERS.join(', ')}, ${OPTIONAL_PRIVATE_FOLDERS.join(', ')})`, ); } } @@ -191,8 +238,13 @@ if (errors.length > 0) { const adapters = existingAdapterFolders(); const absentAdapters = FRAMEWORK_ADAPTER_FOLDERS.filter((folder) => !packageDirExists(folder)); console.log('Package boundary check PASSED'); -console.log(' - core: no DOM/framework/dnd-kit dependencies'); -console.log(' - dom: no framework adapters; depends on core; optional @dnd-kit/dom provider only'); +console.log(' - core: no DOM/framework/intelligence/dnd-kit dependencies'); +console.log( + ' - dom: no framework adapters or intelligence; depends on core; optional @dnd-kit/dom provider only', +); +if (packageDirExists(INTELLIGENCE_FOLDER)) { + console.log(` - intelligence: private optional layer; depends on core only`); +} console.log( ` - adapters present: ${adapters.length === 0 ? '(none)' : adapters.map(npmNameForFolder).join(', ')}`, ); diff --git a/scripts/package-topology.mjs b/scripts/package-topology.mjs index 35f30fb..41ca3d9 100644 --- a/scripts/package-topology.mjs +++ b/scripts/package-topology.mjs @@ -15,9 +15,14 @@ export const PACKAGES_DIR = join(REPO_ROOT, 'packages'); export const CORE_FOLDER = 'core'; export const DOM_FOLDER = 'dom'; +export const INTELLIGENCE_FOLDER = 'intelligence'; + /** Approved JS/DOM framework adapter folders — siblings over `@dndgem/dom`. */ export const FRAMEWORK_ADAPTER_FOLDERS = Object.freeze(['react', 'vue', 'angular', 'svelte']); +/** Optional private workspace layers (not publishable). */ +export const OPTIONAL_PRIVATE_FOLDERS = Object.freeze([INTELLIGENCE_FOLDER]); + /** * Folders that must never appear under `packages/`. * Meta-frameworks are compatibility environments, not packages. From be06f977cf8bf36840a4600f012e395a46dc071e Mon Sep 17 00:00:00 2001 From: davideagosti-dev Date: Thu, 27 Aug 2026 20:11:16 +0100 Subject: [PATCH 2/3] docs(roadmap): mark DND-4.2 complete after closure gate Co-authored-by: Cursor --- docs/architecture/deterministic-planner.md | 2 +- docs/roadmap.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/architecture/deterministic-planner.md b/docs/architecture/deterministic-planner.md index 9da569b..37aeb68 100644 --- a/docs/architecture/deterministic-planner.md +++ b/docs/architecture/deterministic-planner.md @@ -1,6 +1,6 @@ # Deterministic Intelligence Planner (DND-4.2) -**Status:** Private / experimental implementation (Stage B) +**Status:** COMPLETE (DND-4.2 closure gate) — private / experimental workspace package **Package:** `@dndgem/intelligence` (`private: true`, workspace only) **Related:** [layout-intelligence-contract.md](./layout-intelligence-contract.md), [ADR-0018](../adr/ADR-0018-layout-intelligence-boundary.md), [auto-layout-contract.md](./auto-layout-contract.md) diff --git a/docs/roadmap.md b/docs/roadmap.md index 13586a4..950eb20 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,7 +9,7 @@ | Phase 2 | Public Alpha Readiness | **PASS** — Public Alpha **LIVE** | | Phase 3 | Adaptive Auto-Layout | **COMPLETE / RELEASED** (`0.1.0-alpha.1`) | | — | Framework Expansion Gate | **COMPLETE** (DND-FX.1–FX.6; valid public Alpha `0.1.0-alpha.3`) | -| Phase 4 | AI-Assisted Layout Intelligence | **ACTIVE** — DND-4.1 contract COMPLETE; **DND-4.2 Stage B in progress** | +| Phase 4 | AI-Assisted Layout Intelligence | **ACTIVE** — DND-4.1 contract COMPLETE; **DND-4.2 COMPLETE** | Phase 0 is **CLOSED — GO TO TECHNICAL MVP** (historical). @@ -21,7 +21,7 @@ Phase 3 is **COMPLETE / RELEASED**: Adaptive Auto-Layout is live on npm as **`0. Framework Expansion is **COMPLETE**: Cross-framework Alpha **`0.1.0-alpha.3`** is the valid public release (`@alpha`). `0.1.0-alpha.2` is **superseded**. -Phase 4 is **ACTIVE**: **DND-4.1** (Layout Intelligence Contract & Architecture) is **COMPLETE**. Planner implementation begins only with an authorized **DND-4.2** sprint. Phase 4 does **not** require an LLM. +Phase 4 is **ACTIVE**: **DND-4.1** (Layout Intelligence Contract & Architecture) is **COMPLETE**. **DND-4.2** (Deterministic Intelligence Planner) is **COMPLETE**. Phase 4 does **not** require an LLM. During Phase 2 and through Framework Expansion, the GitHub repository remained **PRIVATE** by explicit decision while npm packages were public. After Alpha validation (`0.1.0-alpha.3`), the repository transitions to **public open-source** development so source, Issues, and history are transparent. @@ -443,7 +443,7 @@ Meta-frameworks (Next.js, Nuxt, SvelteKit) are **compatibility environments**, n Planning audit: **PASSED WITH ARCHITECTURAL REFINEMENTS** — see [phase-4-planning-audit.md](./architecture/phase-4-planning-audit.md). -**Entry gate:** READY (Phase 3 COMPLETE; Framework Expansion COMPLETE; public Alpha `0.1.0-alpha.3`). **Sprint map:** APPROVED (5 sprints). **Status:** DND-4.1 **COMPLETE** (contract); **DND-4.2 Stage B in progress**; DND-4.3–DND-4.5 not started. +**Entry gate:** READY (Phase 3 COMPLETE; Framework Expansion COMPLETE; public Alpha `0.1.0-alpha.3`). **Sprint map:** APPROVED (5 sprints). **Status:** DND-4.1 **COMPLETE** (contract); DND-4.2 **COMPLETE**; DND-4.3–DND-4.5 not started. Primary objective: optional higher-level structural/semantic/application planning **without** replacing deterministic solving. @@ -483,7 +483,7 @@ Phase 4 Planning Audit PASSED WITH REFINEMENTS ↓ DND-4.1 Layout Intelligence Contract & Architecture COMPLETE (contract) ↓ -DND-4.2 Deterministic Intelligence Planner **ACTIVE (Stage B)** +DND-4.2 Deterministic Intelligence Planner COMPLETE ↓ DND-4.3 Planner Contract & Optional Integration not started ↓ @@ -515,7 +515,7 @@ DND-4.5 Phase 4 Validation & Alpha Gate not started - **Objective:** Bounded deterministic/heuristic planner that emits Planning Proposals only. - **Out of scope:** Remote model providers; solver semantic change; default-on intelligence. - **Dependencies:** DND-4.1. -- **Status:** **ACTIVE (Stage B implementation)** — private `@dndgem/intelligence` workspace package; prominence heuristic; optional Core `automaticItemOrder`; not complete until final audit gate. +- **Status:** **COMPLETE** — private `@dndgem/intelligence` workspace package; prominence heuristic; optional Core `automaticItemOrder`; closure gate passed. - **Reference:** [deterministic-planner.md](./architecture/deterministic-planner.md) ### DND-4.3 — Planner Contract & Optional Integration From 987aa5036f2a8268b299832a43424532d67486ba Mon Sep 17 00:00:00 2001 From: davideagosti-dev Date: Thu, 27 Aug 2026 20:16:43 +0100 Subject: [PATCH 3/3] style(docs): format roadmap after DND-4.2 closure update Co-authored-by: Cursor --- docs/roadmap.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 950eb20..1041c00 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,14 +2,14 @@ ## Phase status -| Phase | Name | Status | -| ------- | ------------------------------- | ----------------------------------------------------------------------- | -| Phase 0 | Product & Architecture Baseline | **CLOSED** | -| Phase 1 | Technical MVP | **CLOSED** (DND-1.1 → DND-1.8) | -| Phase 2 | Public Alpha Readiness | **PASS** — Public Alpha **LIVE** | -| Phase 3 | Adaptive Auto-Layout | **COMPLETE / RELEASED** (`0.1.0-alpha.1`) | -| — | Framework Expansion Gate | **COMPLETE** (DND-FX.1–FX.6; valid public Alpha `0.1.0-alpha.3`) | -| Phase 4 | AI-Assisted Layout Intelligence | **ACTIVE** — DND-4.1 contract COMPLETE; **DND-4.2 COMPLETE** | +| Phase | Name | Status | +| ------- | ------------------------------- | ---------------------------------------------------------------- | +| Phase 0 | Product & Architecture Baseline | **CLOSED** | +| Phase 1 | Technical MVP | **CLOSED** (DND-1.1 → DND-1.8) | +| Phase 2 | Public Alpha Readiness | **PASS** — Public Alpha **LIVE** | +| Phase 3 | Adaptive Auto-Layout | **COMPLETE / RELEASED** (`0.1.0-alpha.1`) | +| — | Framework Expansion Gate | **COMPLETE** (DND-FX.1–FX.6; valid public Alpha `0.1.0-alpha.3`) | +| Phase 4 | AI-Assisted Layout Intelligence | **ACTIVE** — DND-4.1 contract COMPLETE; **DND-4.2 COMPLETE** | Phase 0 is **CLOSED — GO TO TECHNICAL MVP** (historical).