feat(rete): add schema and typed Fact primitives (P1.1)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 13:36:57 -06:00
commit a027635d27
No known key found for this signature in database
3 changed files with 264 additions and 2 deletions

View file

@ -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";

View file

@ -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<number>().not.toMatchTypeOf<EntityId>();
expectTypeOf<EntityId>().toMatchTypeOf<number>();
});
});
describe("fact()", () => {
type S = { Health: number; Position: string };
it("creates a fact triple { id, attr, value }", () => {
const id = 42 as EntityId;
const f = fact<S, "Health">(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<S, "Position">(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<S, "Health">(id, "Health", 3);
const f2 = fact<S, "Health">(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<S, "Health">(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<S, "Position">(id, "Position", "");
expect(f.id).toBe(0);
expect(f.attr).toBe("Position");
expect(f.value).toBe("");
});
});
describe("Fact<S> type", () => {
it("Fact<S> is a discriminated union keyed by attr", () => {
type S = { Health: number; Name: string };
type F = Fact<S>;
const f: F = { id: 1 as EntityId, attr: "Health", value: 42 };
expect(f.attr).toBe("Health");
});
it("type-level: Fact<S> discriminates value type by attr", () => {
type S = { Health: number; Name: string };
type HealthFact = Extract<Fact<S>, { attr: "Health" }>;
type NameFact = Extract<Fact<S>, { attr: "Name" }>;
expectTypeOf<HealthFact["value"]>().toEqualTypeOf<number>();
expectTypeOf<NameFact["value"]>().toEqualTypeOf<string>();
});
it("type-level: Fact<S>.id is always EntityId", () => {
type S = { X: number };
expectTypeOf<Fact<S>["id"]>().toEqualTypeOf<EntityId>();
});
});

148
packages/rete/src/schema.ts Normal file
View file

@ -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<string, unknown>;
/**
* Discriminated union of all valid fact triples for schema `S`.
*
* For a schema `{ Health: number; Position: string }`, `Fact<S>` 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<S extends SchemaKind> = {
[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<S extends SchemaKind> {
/**
* 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<keyof S & string>;
}
/**
* Options for {@link defineSchema}.
*/
export interface DefineSchemaOptions<S extends SchemaKind> {
/**
* 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<keyof S & string>;
}
/**
* 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<GameSchema>({
* keys: ["Health", "Position", "Active"],
* });
* ```
*/
export function defineSchema<S extends SchemaKind>(
options: DefineSchemaOptions<S> = {}
): Schema<S> {
const seen = new Set<string>();
const deduped: Array<keyof S & string> = [];
const input = options.keys ?? [];
for (const k of input) {
if (!seen.has(k)) {
seen.add(k);
deduped.push(k);
}
}
const schema: Schema<S> = {
// `_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<keyof S & string>,
};
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}`<S>`.
*
* @example
* ```ts
* const id = session.nextId();
* const f = fact<GameSchema, "Health">(id, "Health", 100);
* // f: { id: EntityId; attr: "Health"; value: number }
* ```
*/
export function fact<S extends SchemaKind, K extends keyof S & string>(
id: EntityId,
attr: K,
value: S[K]
): { readonly id: EntityId; readonly attr: K; readonly value: S[K] } {
return Object.freeze({ id, attr, value });
}