feat(rete): add schema and typed Fact primitives (P1.1)
This commit is contained in:
parent
f3a38d44be
commit
a027635d27
3 changed files with 264 additions and 2 deletions
|
|
@ -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";
|
||||
|
|
|
|||
108
packages/rete/src/schema.test.ts
Normal file
108
packages/rete/src/schema.test.ts
Normal 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
148
packages/rete/src/schema.ts
Normal 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 });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue