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
|
// @paratype/rete — Doorenbos-style Rete II rules engine
|
||||||
// Phase 1 implementation begins here
|
export type {
|
||||||
export {};
|
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