From 13e4c465d1be4bb49cd96062b1c2f009a2d6615c Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 14 May 2026 11:07:09 -0500 Subject: [PATCH] feat(forge): ForgeRecipeEntity + ForgeArtifactEntity + registry hookup (#1164 Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of continuum#1164 (design at FORGE-RECIPE-AS-ENTITY.md). TS-side entity classes that wrap the Rust ts-rs types from #1170 (Phase 1a) + register both with the data daemon's EntityRegistry so callers can CRUD forge recipes + artifacts via the standard data/* commands. What ships: - src/system/data/entities/ForgeRecipeEntity.ts — class extending BaseEntity, mirrors the ForgeRecipe Rust shape with field decorators (TextField, JsonField, NumberField). validate() checks required fields. Collection: 'forge_recipes'. - src/system/data/entities/ForgeArtifactEntity.ts — class extending BaseEntity, mirrors ForgeArtifact. ForeignKeyField on recipeId + unique-indexed alloyHash for content-addressable lookup. validate() checks lineage + execution-time fields. Collection: 'forge_artifacts'. - EntityRegistry.ts — imports both entity classes, instantiates each during initializeEntityRegistry() so the decorators register metadata, then registerEntity() with the collection name. Same pattern as the existing entity bulk. - shared/generated/entity_schemas.json regenerates with the two new collections (sha goes from 8cf44380640f to d5c1cff2a1ed6a6c, entity count 55 -> 57). Field naming subtlety: Rust 'version: string' (semver) collides with BaseEntity 'version: number' (ORM row version). Renamed to 'recipeVersion: string' on the entity to avoid the conflict + leave both cross-layer fields workable. Doc-comment notes the drift; Phase 2+ may rename the Rust field for cross-layer alignment. Validation: npm run build:ts clean. Hooks ran without --no-verify. Phase 4 (next slice): forge/run IPC handler that takes a recipeId, runs the foundry pipeline, persists the artifact via data/* commands. Card: continuum#1180. --- .../data-daemon/server/EntityRegistry.ts | 6 + src/shared/generated/entity_schemas.json | 426 +++++++++++++++++- .../data/entities/ForgeArtifactEntity.ts | 156 +++++++ src/system/data/entities/ForgeRecipeEntity.ts | 158 +++++++ 4 files changed, 744 insertions(+), 2 deletions(-) create mode 100644 src/system/data/entities/ForgeArtifactEntity.ts create mode 100644 src/system/data/entities/ForgeRecipeEntity.ts diff --git a/src/daemons/data-daemon/server/EntityRegistry.ts b/src/daemons/data-daemon/server/EntityRegistry.ts index d2d0f6a4c..34da6c6ec 100644 --- a/src/daemons/data-daemon/server/EntityRegistry.ts +++ b/src/daemons/data-daemon/server/EntityRegistry.ts @@ -45,6 +45,8 @@ import { TrainingSessionEntity as FineTuningTrainingSessionEntity } from '../sha import { UserStateEntity } from '../../../system/data/entities/UserStateEntity'; import { ContentTypeEntity } from '../../../system/data/entities/ContentTypeEntity'; import { RecipeEntity } from '../../../system/data/entities/RecipeEntity'; +import { ForgeRecipeEntity } from '../../../system/data/entities/ForgeRecipeEntity'; +import { ForgeArtifactEntity } from '../../../system/data/entities/ForgeArtifactEntity'; import { GenomeEntity } from '../../../system/genome/entities/GenomeEntity'; import { GenomeLayerEntity } from '../../../system/genome/entities/GenomeLayerEntity'; import { AIGenerationEntity } from '../../../system/data/entities/AIGenerationEntity'; @@ -110,6 +112,8 @@ export function initializeEntityRegistry(): void { new UserStateEntity(); new ContentTypeEntity(); new RecipeEntity(); + new ForgeRecipeEntity(); + new ForgeArtifactEntity(); new GenomeEntity(); new GenomeLayerEntity(); new AIGenerationEntity(); @@ -167,6 +171,8 @@ export function initializeEntityRegistry(): void { registerEntity(UserStateEntity.collection, UserStateEntity); registerEntity(ContentTypeEntity.collection, ContentTypeEntity); registerEntity(RecipeEntity.collection, RecipeEntity); + registerEntity(ForgeRecipeEntity.collection, ForgeRecipeEntity); + registerEntity(ForgeArtifactEntity.collection, ForgeArtifactEntity); registerEntity(GenomeEntity.collection, GenomeEntity); registerEntity(GenomeLayerEntity.collection, GenomeLayerEntity); registerEntity(AIGenerationEntity.collection, AIGenerationEntity); diff --git a/src/shared/generated/entity_schemas.json b/src/shared/generated/entity_schemas.json index 585466382..016be6671 100644 --- a/src/shared/generated/entity_schemas.json +++ b/src/shared/generated/entity_schemas.json @@ -1,7 +1,7 @@ { "$schemaVersion": 1, - "$generatedAt": "2026-05-13T17:01:40.910Z", - "$sha256": "27d02233ae3839f7fad6affbd9b4e308a7a08c3bb72329aafa2cb39fcbcd3217", + "$generatedAt": "2026-05-14T16:06:33.742Z", + "$sha256": "d5c1cff2a1ed6a6cb2e9a766ae0e39209fc8e766a300a8b87513eb349e9174e2", "entities": { "users": { "collection": "users", @@ -1158,6 +1158,428 @@ "compositeIndexes": [], "archive": null }, + "forge_recipes": { + "collection": "forge_recipes", + "entityClass": "ForgeRecipeEntity", + "fields": [ + { + "fieldName": "id", + "fieldType": "primary", + "options": { + "unique": true, + "nullable": false + } + }, + { + "fieldName": "createdAt", + "fieldType": "date", + "options": { + "nullable": false, + "index": true + } + }, + { + "fieldName": "updatedAt", + "fieldType": "date", + "options": { + "nullable": false, + "index": true + } + }, + { + "fieldName": "version", + "fieldType": "number", + "options": { + "nullable": false + } + }, + { + "fieldName": "name", + "fieldType": "text", + "options": { + "nullable": false, + "maxLength": 256, + "index": true, + "unique": true + } + }, + { + "fieldName": "recipeVersion", + "fieldType": "text", + "options": { + "nullable": false, + "maxLength": 30 + } + }, + { + "fieldName": "description", + "fieldType": "text", + "options": { + "nullable": false, + "maxLength": 1024 + } + }, + { + "fieldName": "userSummary", + "fieldType": "text", + "options": { + "nullable": false, + "maxLength": 256 + } + }, + { + "fieldName": "author", + "fieldType": "text", + "options": { + "nullable": false, + "maxLength": 256, + "index": true + } + }, + { + "fieldName": "tags", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "license", + "fieldType": "text", + "options": { + "nullable": false, + "maxLength": 30 + } + }, + { + "fieldName": "methodologyPaperUrl", + "fieldType": "text", + "options": { + "nullable": true, + "maxLength": 1024 + } + }, + { + "fieldName": "limitations", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "priorMetricBaselines", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "source", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "stages", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "cycles", + "fieldType": "number", + "options": { + "nullable": false, + "default": 1 + } + }, + { + "fieldName": "calibrationCorpus", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "quantTiers", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "evaluationBenchmarks", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "hardware", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "parentRecipeId", + "fieldType": "text", + "options": { + "nullable": true, + "maxLength": 30, + "index": true + } + }, + { + "fieldName": "authoredAtMs", + "fieldType": "number", + "options": { + "nullable": false + } + }, + { + "fieldName": "updatedAtMs", + "fieldType": "number", + "options": { + "nullable": false + } + } + ], + "compositeIndexes": [], + "archive": null + }, + "forge_artifacts": { + "collection": "forge_artifacts", + "entityClass": "ForgeArtifactEntity", + "fields": [ + { + "fieldName": "id", + "fieldType": "primary", + "options": { + "unique": true, + "nullable": false + } + }, + { + "fieldName": "createdAt", + "fieldType": "date", + "options": { + "nullable": false, + "index": true + } + }, + { + "fieldName": "updatedAt", + "fieldType": "date", + "options": { + "nullable": false, + "index": true + } + }, + { + "fieldName": "version", + "fieldType": "number", + "options": { + "nullable": false + } + }, + { + "fieldName": "recipeId", + "fieldType": "foreign_key", + "options": { + "index": true, + "nullable": false, + "references": "forge_recipes" + } + }, + { + "fieldName": "recipeVersion", + "fieldType": "text", + "options": { + "nullable": false, + "maxLength": 30 + } + }, + { + "fieldName": "recipeName", + "fieldType": "text", + "options": { + "nullable": false, + "maxLength": 256, + "index": true + } + }, + { + "fieldName": "description", + "fieldType": "text", + "options": { + "nullable": false, + "maxLength": 1024 + } + }, + { + "fieldName": "userSummary", + "fieldType": "text", + "options": { + "nullable": false, + "maxLength": 256 + } + }, + { + "fieldName": "author", + "fieldType": "text", + "options": { + "nullable": false, + "maxLength": 256, + "index": true + } + }, + { + "fieldName": "tags", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "license", + "fieldType": "text", + "options": { + "nullable": false, + "maxLength": 30 + } + }, + { + "fieldName": "methodologyPaperUrl", + "fieldType": "text", + "options": { + "nullable": true, + "maxLength": 1024 + } + }, + { + "fieldName": "limitations", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "priorMetricBaselines", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "source", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "calibrationCorpus", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "quantTiers", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "evaluationBenchmarks", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "hardware", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "forgedAtMs", + "fieldType": "number", + "options": { + "nullable": false, + "summary": true + } + }, + { + "fieldName": "durationMinutes", + "fieldType": "number", + "options": { + "nullable": true + } + }, + { + "fieldName": "forgedParamsB", + "fieldType": "number", + "options": { + "nullable": true, + "summary": true + } + }, + { + "fieldName": "activeParamsB", + "fieldType": "number", + "options": { + "nullable": true + } + }, + { + "fieldName": "hardwareVerified", + "fieldType": "json", + "options": { + "nullable": false + } + }, + { + "fieldName": "alloyHash", + "fieldType": "text", + "options": { + "nullable": true, + "maxLength": 256, + "index": true, + "unique": true + } + }, + { + "fieldName": "results", + "fieldType": "json", + "options": { + "nullable": true + } + }, + { + "fieldName": "receipt", + "fieldType": "json", + "options": { + "nullable": true + } + }, + { + "fieldName": "integrity", + "fieldType": "json", + "options": { + "nullable": true + } + } + ], + "compositeIndexes": [], + "archive": null + }, "genomes": { "collection": "genomes", "entityClass": "GenomeEntity", diff --git a/src/system/data/entities/ForgeArtifactEntity.ts b/src/system/data/entities/ForgeArtifactEntity.ts new file mode 100644 index 000000000..7e3f5acd4 --- /dev/null +++ b/src/system/data/entities/ForgeArtifactEntity.ts @@ -0,0 +1,156 @@ +/** + * ForgeArtifact Entity — foundry-generated output for a recipe. + * + * Persists a `ForgeArtifact` (Rust source of truth at + * `src/workers/continuum-core/src/forge/artifact.rs`, ts-rs generated + * type at `shared/generated/forge/ForgeArtifact.ts`) into the Continuum + * data layer. Phase 3 of continuum#1164. + * + * # Why both recipe + artifact get entities + * + * The artifact carries a SNAPSHOT of the recipe fields at run time + * (denormalized so the artifact card renders without re-fetching the + * recipe). The artifact also carries execution outputs only the foundry + * knows. Recipe lineage is via `recipeId` + `recipeVersion` (frozen at + * run time so a later recipe edit can't retroactively rewrite what + * this artifact claims to come from). + */ + +import type { UUID } from '../../core/types/CrossPlatformUUID'; +import { BaseEntity } from './BaseEntity'; +import { TextField, JsonField, NumberField, ForeignKeyField, TEXT_LENGTH } from '../decorators/FieldDecorators'; +import type { + AlloyHardware, + AlloySource, + BenchmarkDef, + CorpusRef, + HardwareProfile, + PriorBaseline, + QuantTier, +} from '@shared/generated/forge'; + +export class ForgeArtifactEntity extends BaseEntity { + static readonly collection = 'forge_artifacts'; + + get collection(): string { + return ForgeArtifactEntity.collection; + } + + // === Recipe lineage (frozen at run time) === + + @ForeignKeyField({ references: 'forge_recipes', index: true }) + recipeId!: UUID; + + /** + * Recipe version at run time (semver). Pinned so a later recipe + * revision doesn't retroactively change what this artifact claims + * to come from. + */ + @TextField({ maxLength: TEXT_LENGTH.SHORT }) + recipeVersion!: string; + + /** Recipe `name` snapshot — denormalized for card-render efficiency. */ + @TextField({ maxLength: TEXT_LENGTH.DEFAULT, index: true }) + recipeName!: string; + + // === Snapshot of recipe authored fields === + + @TextField({ maxLength: TEXT_LENGTH.LONG }) + description!: string; + + @TextField({ maxLength: TEXT_LENGTH.DEFAULT }) + userSummary!: string; + + @TextField({ maxLength: TEXT_LENGTH.DEFAULT, index: true }) + author!: string; + + @JsonField() + tags!: string[]; + + @TextField({ maxLength: TEXT_LENGTH.SHORT }) + license!: string; + + @TextField({ maxLength: TEXT_LENGTH.LONG, nullable: true }) + methodologyPaperUrl?: string; + + @JsonField() + limitations!: string[]; + + @JsonField() + priorMetricBaselines!: PriorBaseline[]; + + @JsonField() + source!: AlloySource; + + @JsonField() + calibrationCorpus!: CorpusRef; + + @JsonField() + quantTiers!: QuantTier[]; + + @JsonField() + evaluationBenchmarks!: BenchmarkDef[]; + + @JsonField() + hardware!: AlloyHardware; + + // === Execution outputs (only the foundry knows these) === + + @NumberField({ summary: true }) + forgedAtMs!: number; + + @NumberField({ nullable: true }) + durationMinutes?: number; + + @NumberField({ nullable: true, summary: true }) + forgedParamsB?: number; + + @NumberField({ nullable: true }) + activeParamsB?: number; + + @JsonField() + hardwareVerified!: HardwareProfile[]; + + /** + * Content-addressable hash of the populated artifact JSON. Used as + * the verification anchor by publish_model.py and by the proof- + * contract trust layer (see grid/FORGE-ALLOY-PROOF-CONTRACTS.md). + * Format: "sha256:" matching admission's content_hash convention. + */ + @TextField({ maxLength: TEXT_LENGTH.DEFAULT, nullable: true, index: true, unique: true }) + alloyHash?: string; + + /** + * Full execution results blob. v1 carries this as opaque JSON + * matching the existing Python AlloyResults shape. Phase 2 of #1164 + * types this as a first-class Rust struct once the foundry executor + * needs it. + */ + @JsonField({ nullable: true }) + results?: unknown; + + /** Publication receipt blob. Phase 2 typing same as `results`. */ + @JsonField({ nullable: true }) + receipt?: unknown; + + /** Integrity attestation blob. Phase 2 typing same as `results`. */ + @JsonField({ nullable: true }) + integrity?: unknown; + + /** Required by BaseEntity. v1: minimal validation. */ + validate(): { success: boolean; error?: string } { + if (!this.recipeId) { + return { success: false, error: 'ForgeArtifact.recipeId must be set (lineage)' }; + } + if (!this.recipeVersion || this.recipeVersion.trim().length === 0) { + return { success: false, error: 'ForgeArtifact.recipeVersion must be non-empty (snapshot)' }; + } + if (!this.recipeName || this.recipeName.trim().length === 0) { + return { success: false, error: 'ForgeArtifact.recipeName must be non-empty (snapshot)' }; + } + if (!this.forgedAtMs || this.forgedAtMs <= 0) { + return { success: false, error: 'ForgeArtifact.forgedAtMs must be set (foundry start time)' }; + } + return { success: true }; + } +} diff --git a/src/system/data/entities/ForgeRecipeEntity.ts b/src/system/data/entities/ForgeRecipeEntity.ts new file mode 100644 index 000000000..918370a7c --- /dev/null +++ b/src/system/data/entities/ForgeRecipeEntity.ts @@ -0,0 +1,158 @@ +/** + * ForgeRecipe Entity — authored input for the foundry pipeline. + * + * Persists a `ForgeRecipe` (Rust source of truth at + * `src/workers/continuum-core/src/forge/recipe.rs`, ts-rs generated + * type at `shared/generated/forge/ForgeRecipe.ts`) into the Continuum + * data layer so callers can CRUD recipes via standard `data/*` + * commands. Phase 3 of continuum#1164 (design at + * `docs/architecture/FORGE-RECIPE-AS-ENTITY.md`). + * + * # Field shape + * + * Field declarations mirror the Rust struct one-to-one. The Rust + * `#[derive(TS)]` is the source of truth for the JSON shape on the + * wire; this class registers SQL schema metadata for the data daemon's + * sqlite/postgres adapter. Drift between the two is a known + * tech-debt cost (see Phase 3 follow-up: auto-derive entity decorators + * from ts-rs metadata). + */ + +import type { UUID } from '../../core/types/CrossPlatformUUID'; +import { BaseEntity } from './BaseEntity'; +import { TextField, JsonField, NumberField, TEXT_LENGTH } from '../decorators/FieldDecorators'; +import type { + AlloyHardware, + AlloySource, + BenchmarkDef, + CorpusRef, + PriorBaseline, + QuantTier, +} from '@shared/generated/forge'; + +export class ForgeRecipeEntity extends BaseEntity { + static readonly collection = 'forge_recipes'; + + get collection(): string { + return ForgeRecipeEntity.collection; + } + + // === Identity === + + @TextField({ maxLength: TEXT_LENGTH.DEFAULT, index: true, unique: true }) + name!: string; + + /** + * Recipe semver. Named `recipeVersion` (not `version`) to avoid + * collision with BaseEntity's row-version `version: number` (ORM + * optimistic-concurrency anchor). The Rust source-of-truth field + * is `version: string`; callers populating this entity must map + * `recipe.version -> recipeVersion`. Phase 2+ may rename the Rust + * field too for cross-layer alignment. + */ + @TextField({ maxLength: TEXT_LENGTH.SHORT }) + recipeVersion!: string; + + @TextField({ maxLength: TEXT_LENGTH.LONG }) + description!: string; + + /** One-line plain-English headline. */ + @TextField({ maxLength: TEXT_LENGTH.DEFAULT }) + userSummary!: string; + + @TextField({ maxLength: TEXT_LENGTH.DEFAULT, index: true }) + author!: string; + + @JsonField() + tags!: string[]; + + @TextField({ maxLength: TEXT_LENGTH.SHORT }) + license!: string; + + // === Methodology / falsifiability prose === + + @TextField({ maxLength: TEXT_LENGTH.LONG, nullable: true }) + methodologyPaperUrl?: string; + + @JsonField() + limitations!: string[]; + + @JsonField() + priorMetricBaselines!: PriorBaseline[]; + + // === Source === + + @JsonField() + source!: AlloySource; + + // === Pipeline === + + /** + * Stages as opaque JSON values matching the existing AlloyStage + * discriminated union from forge-alloy/python/forge_alloy/types.py. + * Phase 2 of #1164 replaces this with a typed RecipeStage enum (Rust + * side); the JSON shape is unchanged when that lands. + */ + @JsonField() + stages!: unknown[]; + + @NumberField({ default: 1 }) + cycles!: number; + + // === Calibration / eval inputs === + + @JsonField() + calibrationCorpus!: CorpusRef; + + @JsonField() + quantTiers!: QuantTier[]; + + @JsonField() + evaluationBenchmarks!: BenchmarkDef[]; + + // === Hardware target === + + @JsonField() + hardware!: AlloyHardware; + + // === Lineage === + + /** + * Parent recipe id, if this recipe was forked from another. v1 + * lineage is one-directional (recipe -> recipe); bidirectional + * lineage (recipe <- artifact) is a future `parentArtifactIds` field + * per consensus position #9 on continuum#1165. + */ + @TextField({ maxLength: TEXT_LENGTH.SHORT, nullable: true, index: true }) + parentRecipeId?: UUID; + + // === Timestamps === + + /** + * Epoch milliseconds UTC. Same convention as Engram.admittedAtMs from + * the engram thread (#1129). Stored as @NumberField (sqlite INTEGER / + * postgres BIGINT) for direct ordering in `data/list orderBy`. + */ + @NumberField() + authoredAtMs!: number; + + @NumberField() + updatedAtMs!: number; + + /** Required by BaseEntity. v1: minimal validation. */ + validate(): { success: boolean; error?: string } { + if (!this.name || this.name.trim().length === 0) { + return { success: false, error: 'ForgeRecipe.name must be non-empty' }; + } + if (!this.recipeVersion || this.recipeVersion.trim().length === 0) { + return { success: false, error: 'ForgeRecipe.recipeVersion must be non-empty (semver)' }; + } + if (!this.source) { + return { success: false, error: 'ForgeRecipe.source must be set (baseModel + architecture)' }; + } + if (this.cycles < 1) { + return { success: false, error: 'ForgeRecipe.cycles must be >= 1' }; + } + return { success: true }; + } +}