From a027635d2709b024da742c93c5119c4fea7842a5 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 13:36:57 -0600 Subject: [PATCH] feat(rete): add schema and typed Fact primitives (P1.1) --- packages/rete/src/index.ts | 10 ++- packages/rete/src/schema.test.ts | 108 ++++++++++++++++++++++ packages/rete/src/schema.ts | 148 +++++++++++++++++++++++++++++++ 3 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 packages/rete/src/schema.test.ts create mode 100644 packages/rete/src/schema.ts diff --git a/packages/rete/src/index.ts b/packages/rete/src/index.ts index 5e2a1b7..ab09de7 100644 --- a/packages/rete/src/index.ts +++ b/packages/rete/src/index.ts @@ -1,3 +1,9 @@ // @paratype/rete — Doorenbos-style Rete II rules engine -// Phase 1 implementation begins here -export {}; +export type { + EntityId, + Fact, + Schema, + SchemaKind, + DefineSchemaOptions, +} from "./schema.js"; +export { defineSchema, fact } from "./schema.js"; diff --git a/packages/rete/src/schema.test.ts b/packages/rete/src/schema.test.ts new file mode 100644 index 0000000..ea88b82 --- /dev/null +++ b/packages/rete/src/schema.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, expectTypeOf } from "vitest"; +import { defineSchema, fact, type Fact, type EntityId } from "./schema.js"; + +describe("defineSchema", () => { + it("returns a schema object with keyed attribute entries", () => { + const schema = defineSchema<{ Health: number; Name: string; Active: boolean }>(); + expect(schema).toBeDefined(); + expect(schema.keys).toEqual([]); + }); + + it("accepts an explicit keys list for runtime introspection", () => { + const schema = defineSchema<{ Health: number; Name: string }>({ + keys: ["Health", "Name"], + }); + expect(schema.keys).toContain("Health"); + expect(schema.keys).toContain("Name"); + expect(schema.keys).toHaveLength(2); + }); + + it("returns an immutable-shaped descriptor (frozen keys array)", () => { + const schema = defineSchema<{ X: number }>({ keys: ["X"] }); + // keys array is a ReadonlyArray at type-level; at runtime it's frozen + expect(Object.isFrozen(schema.keys)).toBe(true); + expect(Object.isFrozen(schema)).toBe(true); + }); + + it("deduplicates and preserves first-seen order for keys", () => { + const schema = defineSchema<{ A: number; B: string }>({ keys: ["A", "B", "A"] }); + expect(schema.keys).toEqual(["A", "B"]); + }); +}); + +describe("EntityId", () => { + it("is a branded number type (compile-time brand, runtime number)", () => { + const id = 1 as EntityId; + expect(typeof id).toBe("number"); + expect(id).toBe(1); + }); + + it("type-level: EntityId cannot be assigned from plain number without cast", () => { + // Compile-time assertion via expectTypeOf + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + }); +}); + +describe("fact()", () => { + type S = { Health: number; Position: string }; + + it("creates a fact triple { id, attr, value }", () => { + const id = 42 as EntityId; + const f = fact(id, "Health", 100); + expect(f.id).toBe(42); + expect(f.attr).toBe("Health"); + expect(f.value).toBe(100); + }); + + it("creates a string-valued fact", () => { + const id = 1 as EntityId; + const f = fact(id, "Position", "e4"); + expect(f).toEqual({ id: 1, attr: "Position", value: "e4" }); + }); + + it("facts with same id+attr but different values are distinct objects", () => { + const id = 1 as EntityId; + const f1 = fact(id, "Health", 3); + const f2 = fact(id, "Health", 2); + expect(f1).not.toBe(f2); + expect(f1.value).toBe(3); + expect(f2.value).toBe(2); + }); + + it("produces a frozen object (immutable fact)", () => { + const id = 7 as EntityId; + const f = fact(id, "Health", 10); + expect(Object.isFrozen(f)).toBe(true); + }); + + it("preserves exact id, attr, and value without coercion", () => { + const id = 0 as EntityId; + const f = fact(id, "Position", ""); + expect(f.id).toBe(0); + expect(f.attr).toBe("Position"); + expect(f.value).toBe(""); + }); +}); + +describe("Fact type", () => { + it("Fact is a discriminated union keyed by attr", () => { + type S = { Health: number; Name: string }; + type F = Fact; + const f: F = { id: 1 as EntityId, attr: "Health", value: 42 }; + expect(f.attr).toBe("Health"); + }); + + it("type-level: Fact discriminates value type by attr", () => { + type S = { Health: number; Name: string }; + type HealthFact = Extract, { attr: "Health" }>; + type NameFact = Extract, { attr: "Name" }>; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("type-level: Fact.id is always EntityId", () => { + type S = { X: number }; + expectTypeOf["id"]>().toEqualTypeOf(); + }); +}); diff --git a/packages/rete/src/schema.ts b/packages/rete/src/schema.ts new file mode 100644 index 0000000..dcb6283 --- /dev/null +++ b/packages/rete/src/schema.ts @@ -0,0 +1,148 @@ +/** + * @paratype/rete — Schema and Fact primitives + * + * Implements the EAV (Entity-Attribute-Value) fact model specified in + * `packages/rete/SPEC.md §Fact Model` and the branded id type from §ID Authority. + * + * Working memory stores triples `(id: EntityId, attr: AttrKey, value: AttrValue)`. + * Each `(id, attr)` pair stores exactly one value; insertion over an existing pair + * is an update (see §Match Refraction). + * + * The schema `S` is a plain TypeScript object-type that maps attribute names to + * their value types. The schema is the single source of truth for attribute + * typing and is consumed by both the public API (for inference at call sites) + * and by JSON rule validation (see §JSON Rule Schema). + */ + +/** + * Compile-time branded entity identifier. + * + * At runtime, `EntityId` is a plain `number`. The brand exists only in the + * type system to prevent accidental interchange with ordinary numbers at + * call sites. Construct one by casting a freshly-minted number: + * `session.nextId()` (in standalone sessions) or the server (in multiplayer). + */ +export type EntityId = number & { readonly __brand: "EntityId" }; + +/** + * Shape of a schema type parameter. + * + * A schema maps attribute name (string key) to the TypeScript type of that + * attribute's value. Users do not construct `SchemaKind` directly; they pass + * an interface as a generic argument to {@link defineSchema}. + */ +export type SchemaKind = Record; + +/** + * Discriminated union of all valid fact triples for schema `S`. + * + * For a schema `{ Health: number; Position: string }`, `Fact` evaluates to + * `{ id: EntityId; attr: "Health"; value: number } + * | { id: EntityId; attr: "Position"; value: string }`. + * + * Narrowing on `attr` refines `value` to the corresponding schema type. + */ +export type Fact = { + [K in keyof S & string]: { + readonly id: EntityId; + readonly attr: K; + readonly value: S[K]; + }; +}[keyof S & string]; + +/** + * Runtime descriptor returned by {@link defineSchema}. + * + * The phantom `_type` field carries the schema type `S` through inference so + * that downstream APIs (Session, rules, queries) can recover the user's + * attribute typing without requiring it to be re-specified. It is declared + * optional at runtime and is never populated with a value; at the type level + * it constrains `S` through variance. + * + * The `keys` array is a frozen, deduplicated list of attribute names for + * runtime introspection (e.g. JSON rule validation, debug dumps). `keys` MAY + * be empty if the caller only needs type-level schema information. + */ +export interface Schema { + /** + * Phantom type carrier. Never read at runtime. Present solely so that the + * generic parameter `S` participates in type inference through the return + * value of {@link defineSchema}. + */ + readonly _type?: S; + /** Frozen, deduplicated attribute-name list in first-seen order. */ + readonly keys: ReadonlyArray; +} + +/** + * Options for {@link defineSchema}. + */ +export interface DefineSchemaOptions { + /** + * Attribute names to record at runtime. Duplicates are removed, first-seen + * order is preserved. If omitted, `keys` defaults to an empty array — the + * schema descriptor still carries full type information via its generic. + */ + readonly keys?: ReadonlyArray; +} + +/** + * Define a schema descriptor for use with a Session. + * + * The schema is a type-level contract: its runtime value is a lightweight + * descriptor that carries the type parameter `S` for inference and optionally + * records the attribute-name list for introspection. + * + * @example + * ```ts + * interface GameSchema { + * Health: number; + * Position: string; + * Active: boolean; + * } + * const schema = defineSchema({ + * keys: ["Health", "Position", "Active"], + * }); + * ``` + */ +export function defineSchema( + options: DefineSchemaOptions = {} +): Schema { + const seen = new Set(); + const deduped: Array = []; + const input = options.keys ?? []; + for (const k of input) { + if (!seen.has(k)) { + seen.add(k); + deduped.push(k); + } + } + const schema: Schema = { + // `_type` is intentionally omitted at runtime; it exists only in the type + // system as a phantom carrier for the generic parameter `S`. + keys: Object.freeze(deduped) as ReadonlyArray, + }; + return Object.freeze(schema); +} + +/** + * Construct a typed, frozen fact triple. + * + * The generic parameters are explicit to preserve the literal `attr` type in + * the returned fact, which is essential for downstream discrimination against + * {@link Fact}``. + * + * @example + * ```ts + * const id = session.nextId(); + * const f = fact(id, "Health", 100); + * // f: { id: EntityId; attr: "Health"; value: number } + * ``` + */ +export function fact( + id: EntityId, + attr: K, + value: S[K] +): { readonly id: EntityId; readonly attr: K; readonly value: S[K] } { + return Object.freeze({ id, attr, value }); +}