diff --git a/package-lock.json b/package-lock.json index 5eaa85a84..e850c8e85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -739,6 +739,10 @@ "resolved": "packages/evals", "link": true }, + "node_modules/@earendil-works/diffex-evolution": { + "resolved": "packages/evolution", + "link": true + }, "node_modules/@earendil-works/diffex-harness-state": { "resolved": "packages/harness-state", "link": true @@ -5719,6 +5723,40 @@ "dev": true, "license": "MIT" }, + "packages/evolution": { + "name": "@earendil-works/diffex-evolution", + "version": "0.2.0", + "license": "MIT", + "dependencies": { + "@earendil-works/diffex-harness-state": "0.2.0", + "@earendil-works/diffex-harness-trace": "0.2.0" + }, + "devDependencies": { + "@types/node": "24.12.4", + "shx": "0.4.0", + "vitest": "4.1.9" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "packages/evolution/node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "packages/evolution/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "packages/harness-state": { "name": "@earendil-works/diffex-harness-state", "version": "0.2.0", diff --git a/package.json b/package.json index 9506df2b5..31a6af94f 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ ], "scripts": { "clean": "npm run clean --workspaces", - "build": "cd packages/harness-state && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../harness-trace && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", - "build:offline": "cd packages/harness-state && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../harness-trace && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", + "build": "cd packages/harness-state && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../harness-trace && npm run build && cd ../evolution && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", + "build:offline": "cd packages/harness-state && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../harness-trace && npm run build && cd ../evolution && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", "check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke", "check:browser-smoke": "node scripts/check-browser-smoke.mjs", "check:pinned-deps": "node scripts/check-pinned-deps.mjs", diff --git a/packages/evolution/package.json b/packages/evolution/package.json new file mode 100644 index 000000000..278408651 --- /dev/null +++ b/packages/evolution/package.json @@ -0,0 +1,43 @@ +{ + "name": "@earendil-works/diffex-evolution", + "version": "0.2.0", + "description": "Private evolution pipeline contracts and orchestration for Diffex", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "shx rm -rf dist", + "build": "tsgo -p tsconfig.build.json", + "test": "vitest --run", + "prepublishOnly": "npm run clean && npm run build" + }, + "author": "Earendil Works", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/diffexai/diffex.git", + "directory": "packages/evolution" + }, + "engines": { + "node": ">=22.19.0" + }, + "dependencies": { + "@earendil-works/diffex-harness-state": "0.2.0", + "@earendil-works/diffex-harness-trace": "0.2.0" + }, + "devDependencies": { + "@types/node": "24.12.4", + "shx": "0.4.0", + "vitest": "4.1.9" + } +} diff --git a/packages/evolution/src/contracts.ts b/packages/evolution/src/contracts.ts new file mode 100644 index 000000000..0729ae6a0 --- /dev/null +++ b/packages/evolution/src/contracts.ts @@ -0,0 +1,361 @@ +import type { HarnessVersion } from "@earendil-works/diffex-harness-state"; +import type { + MaterializationIntegrity, + MaterializedInvocation, + MaterializedTerminationStatus, +} from "@earendil-works/diffex-harness-trace"; + +export const EVOLUTION_SCHEMA_VERSION = 1 as const; + +export type JsonPrimitive = boolean | number | string | null; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +export type JsonObject = Readonly<{ [key: string]: JsonValue }>; + +export interface EvolutionScope { + readonly repositoryId: string; + readonly scopeId: string; +} + +export interface EvolutionModelIdentity { + readonly provider: string; + readonly model: string; + readonly configurationDigest: string; +} + +export interface EvolutionArtifactProducer { + readonly name: string; + readonly version: string; + readonly promptVersion: string | null; + readonly model: EvolutionModelIdentity | null; +} + +export interface EvolutionArtifactMetadata { + readonly schemaVersion: typeof EVOLUTION_SCHEMA_VERSION; + readonly artifactId: string; + readonly producer: EvolutionArtifactProducer; + readonly sourceIds: readonly string[]; + readonly contentDigest: string; + readonly scope: EvolutionScope; + readonly createdAt: string; +} + +export interface ImmutableHarnessSnapshot { + readonly harnessVersion: HarnessVersion; + readonly baseVersion: HarnessVersion | null; + readonly memory: string; + readonly memoryContentDigest: string; +} + +export type EvolutionEvidenceOrigin = "product-runtime" | "evolution-pipeline"; + +export interface EvolutionEvidenceSource { + readonly sourceId: string; + readonly origin: EvolutionEvidenceOrigin; + readonly contentDigest: string; + readonly invocation: MaterializedInvocation; +} + +export interface EvolutionInputSnapshot { + readonly metadata: EvolutionArtifactMetadata; + readonly harness: ImmutableHarnessSnapshot; + readonly evidenceSources: readonly EvolutionEvidenceSource[]; +} + +export interface EvidenceCorpusDocument { + readonly documentId: string; + readonly sourceId: string; + readonly contentDigest: string; + readonly materializedInvocationVersion: number; + readonly traceId: string | null; + readonly integrity: MaterializationIntegrity; + readonly terminationStatus: MaterializedTerminationStatus; + readonly evidenceIds: readonly string[]; +} + +export interface ExcludedEvidenceSource { + readonly sourceId: string; + readonly reason: "evolution-pipeline-origin"; +} + +export interface EvidenceCorpusManifest { + readonly metadata: EvolutionArtifactMetadata; + readonly documents: readonly EvidenceCorpusDocument[]; + readonly excludedSources: readonly ExcludedEvidenceSource[]; +} + +export interface InstructionDocumentSnapshot { + readonly path: string; + readonly precedence: number; + readonly content: string; + readonly contentDigest: string; +} + +export interface InstructionSnapshot { + readonly metadata: EvolutionArtifactMetadata; + readonly documents: readonly InstructionDocumentSnapshot[]; +} + +export type ReflectionNodeStatus = "pending" | "complete" | "failed"; + +export interface ReflectionNode { + readonly metadata: EvolutionArtifactMetadata; + readonly nodeId: string; + readonly parentNodeId: string | null; + readonly childNodeIds: readonly string[]; + readonly evidenceIds: readonly string[]; + readonly lessonIds: readonly string[]; + readonly status: ReflectionNodeStatus; +} + +export type LessonKind = "constraint" | "failure" | "instruction" | "strategy" | "unresolved"; + +export interface Lesson { + readonly metadata: EvolutionArtifactMetadata; + readonly lessonId: string; + readonly kind: LessonKind; + readonly summary: string; + readonly evidenceIds: readonly string[]; + readonly counterevidenceIds: readonly string[]; + readonly scope: EvolutionScope; + readonly uncertainty: string | null; +} + +export interface EvolutionMemoryTarget { + readonly kind: "evolution-memory"; + readonly expectedHarnessVersion: HarnessVersion; +} + +export interface SkillTarget { + readonly kind: "skill"; + readonly skillName: string; +} + +export interface OrchestrationSourceTarget { + readonly kind: "orchestration-source"; + readonly sourcePath: string; +} + +export type EvolutionTarget = EvolutionMemoryTarget | SkillTarget | OrchestrationSourceTarget; + +interface EvolutionProposalBase { + readonly metadata: EvolutionArtifactMetadata; + readonly proposalId: string; + readonly summary: string; + readonly lessonIds: readonly string[]; + readonly evidenceIds: readonly string[]; +} + +export interface EvolutionMemoryProposal extends EvolutionProposalBase { + readonly disposition: "actionable"; + readonly target: EvolutionMemoryTarget; + readonly executablePayload: JsonObject; +} + +export interface DeferredEvolutionProposal extends EvolutionProposalBase { + readonly disposition: "deferred"; + readonly target: SkillTarget | OrchestrationSourceTarget; + readonly finding: string; +} + +export type EvolutionProposal = EvolutionMemoryProposal | DeferredEvolutionProposal; + +export type ValidationSeverity = "error" | "warning"; + +export interface ValidationIssue { + readonly code: string; + readonly message: string; + readonly severity: ValidationSeverity; + readonly proposalId?: string; +} + +export interface ProposalValidationResult { + readonly proposalId: string; + readonly disposition: "accepted" | "deferred" | "rejected"; + readonly issues: readonly ValidationIssue[]; +} + +export interface ValidationReport { + readonly metadata: EvolutionArtifactMetadata; + readonly proposalResults: readonly ProposalValidationResult[]; +} + +export type ScoredProposalDisposition = "deferred" | "rejected" | "selected" | "superseded"; + +export interface ScoredProposal { + readonly metadata: EvolutionArtifactMetadata; + readonly proposal: EvolutionProposal; + readonly score: number | null; + readonly disposition: ScoredProposalDisposition; + readonly reasonCodes: readonly string[]; +} + +export interface EvolutionMutation { + readonly mutationId: string; + readonly proposalId: string; + readonly target: EvolutionMemoryTarget; + readonly payload: JsonObject; +} + +export interface MutationBatch { + readonly metadata: EvolutionArtifactMetadata; + readonly batchId: string; + readonly targetKind: "evolution-memory"; + readonly mutations: readonly EvolutionMutation[]; +} + +export interface MutationWriteResult { + readonly batchId: string; + readonly status: "applied" | "failed"; + readonly resultingHarnessVersion: HarnessVersion | null; + readonly message: string | null; +} + +export interface CreatedCheckpoint { + readonly schemaVersion: typeof EVOLUTION_SCHEMA_VERSION; + readonly stage: "created"; +} + +export interface CorpusCheckpoint { + readonly schemaVersion: typeof EVOLUTION_SCHEMA_VERSION; + readonly stage: "corpus-prepared"; + readonly corpusManifestId: string; + readonly instructionSnapshotId: string; +} + +export interface ReflectedCheckpoint { + readonly schemaVersion: typeof EVOLUTION_SCHEMA_VERSION; + readonly stage: "reflected"; + readonly reflectionNodeIds: readonly string[]; + readonly lessonIds: readonly string[]; +} + +export interface CuratedCheckpoint { + readonly schemaVersion: typeof EVOLUTION_SCHEMA_VERSION; + readonly stage: "curated"; + readonly proposalIds: readonly string[]; +} + +export interface ValidatedCheckpoint { + readonly schemaVersion: typeof EVOLUTION_SCHEMA_VERSION; + readonly stage: "validated"; + readonly validationReportId: string; +} + +export interface ScoredCheckpoint { + readonly schemaVersion: typeof EVOLUTION_SCHEMA_VERSION; + readonly stage: "scored"; + readonly scoredProposalIds: readonly string[]; +} + +export interface WrittenCheckpoint { + readonly schemaVersion: typeof EVOLUTION_SCHEMA_VERSION; + readonly stage: "written"; + readonly mutationBatchId: string | null; + readonly writeResult: MutationWriteResult | null; +} + +export interface CompletedCheckpoint { + readonly schemaVersion: typeof EVOLUTION_SCHEMA_VERSION; + readonly stage: "completed"; + readonly resultingHarnessVersion: HarnessVersion | null; +} + +export type ActiveEvolutionStage = + | "created" + | "corpus-prepared" + | "reflected" + | "curated" + | "validated" + | "scored" + | "written"; + +export interface FailedCheckpoint { + readonly schemaVersion: typeof EVOLUTION_SCHEMA_VERSION; + readonly stage: "failed"; + readonly failedStage: ActiveEvolutionStage; + readonly message: string; +} + +export type EvolutionCheckpoint = + | CreatedCheckpoint + | CorpusCheckpoint + | ReflectedCheckpoint + | CuratedCheckpoint + | ValidatedCheckpoint + | ScoredCheckpoint + | WrittenCheckpoint + | CompletedCheckpoint + | FailedCheckpoint; + +export interface EvolutionJob { + readonly metadata: EvolutionArtifactMetadata; + readonly jobId: string; + readonly inputSnapshotId: string; + readonly status: "completed" | "failed" | "running"; + readonly checkpoint: EvolutionCheckpoint; + readonly updatedAt: string; +} + +export type EvolutionProposalLedgerDisposition = + | "applied" + | "deferred" + | "failed" + | "rejected" + | "rolled-back" + | "superseded"; + +interface EvolutionLedgerEventBase { + readonly metadata: EvolutionArtifactMetadata; + readonly eventId: string; + readonly jobId: string; +} + +export interface JobCreatedLedgerEvent extends EvolutionLedgerEventBase { + readonly type: "job-created"; + readonly inputSnapshotId: string; +} + +export interface CheckpointSavedLedgerEvent extends EvolutionLedgerEventBase { + readonly type: "checkpoint-saved"; + readonly checkpoint: EvolutionCheckpoint; +} + +export interface ProposalDispositionLedgerEvent extends EvolutionLedgerEventBase { + readonly type: "proposal-disposition"; + readonly proposalId: string; + readonly disposition: EvolutionProposalLedgerDisposition; + readonly reasonCodes: readonly string[]; +} + +export interface MutationWrittenLedgerEvent extends EvolutionLedgerEventBase { + readonly type: "mutation-written"; + readonly batchId: string; + readonly writeResult: MutationWriteResult; +} + +export interface JobCompletedLedgerEvent extends EvolutionLedgerEventBase { + readonly type: "job-completed"; + readonly resultingHarnessVersion: HarnessVersion | null; +} + +export interface JobFailedLedgerEvent extends EvolutionLedgerEventBase { + readonly type: "job-failed"; + readonly failedStage: FailedCheckpoint["failedStage"]; + readonly message: string; +} + +export interface ProposalRolledBackLedgerEvent extends EvolutionLedgerEventBase { + readonly type: "proposal-rolled-back"; + readonly proposalId: string; + readonly fromHarnessVersion: HarnessVersion; + readonly toHarnessVersion: HarnessVersion; +} + +export type EvolutionLedgerEvent = + | JobCreatedLedgerEvent + | CheckpointSavedLedgerEvent + | ProposalDispositionLedgerEvent + | MutationWrittenLedgerEvent + | JobCompletedLedgerEvent + | JobFailedLedgerEvent + | ProposalRolledBackLedgerEvent; diff --git a/packages/evolution/src/index.ts b/packages/evolution/src/index.ts new file mode 100644 index 000000000..b41307272 --- /dev/null +++ b/packages/evolution/src/index.ts @@ -0,0 +1,95 @@ +export { + type ActiveEvolutionStage, + type CheckpointSavedLedgerEvent, + type CompletedCheckpoint, + type CorpusCheckpoint, + type CreatedCheckpoint, + type CuratedCheckpoint, + type DeferredEvolutionProposal, + EVOLUTION_SCHEMA_VERSION, + type EvidenceCorpusDocument, + type EvidenceCorpusManifest, + type EvolutionArtifactMetadata, + type EvolutionArtifactProducer, + type EvolutionCheckpoint, + type EvolutionEvidenceOrigin, + type EvolutionEvidenceSource, + type EvolutionInputSnapshot, + type EvolutionJob, + type EvolutionLedgerEvent, + type EvolutionMemoryProposal, + type EvolutionMemoryTarget, + type EvolutionModelIdentity, + type EvolutionMutation, + type EvolutionProposal, + type EvolutionProposalLedgerDisposition, + type EvolutionScope, + type EvolutionTarget, + type ExcludedEvidenceSource, + type FailedCheckpoint, + type ImmutableHarnessSnapshot, + type InstructionDocumentSnapshot, + type InstructionSnapshot, + type JobCompletedLedgerEvent, + type JobCreatedLedgerEvent, + type JobFailedLedgerEvent, + type JsonObject, + type JsonPrimitive, + type JsonValue, + type Lesson, + type LessonKind, + type MutationBatch, + type MutationWriteResult, + type MutationWrittenLedgerEvent, + type OrchestrationSourceTarget, + type ProposalDispositionLedgerEvent, + type ProposalRolledBackLedgerEvent, + type ProposalValidationResult, + type ReflectedCheckpoint, + type ReflectionNode, + type ReflectionNodeStatus, + type ScoredCheckpoint, + type ScoredProposal, + type ScoredProposalDisposition, + type SkillTarget, + type ValidatedCheckpoint, + type ValidationIssue, + type ValidationReport, + type ValidationSeverity, + type WrittenCheckpoint, +} from "./contracts.ts"; +export { EvolutionPipeline, type EvolutionPipelineDependencies, type EvolutionRunResult } from "./pipeline.ts"; +export type { + Curator, + CuratorInput, + EvolutionArtifact, + EvolutionArtifactStore, + EvolutionClock, + EvolutionEventSink, + EvolutionIdGenerator, + EvolutionJobStore, + EvolutionLedger, + EvolutionModelExecutor, + EvolutionModelRequest, + EvolutionModelResponse, + EvolutionProgressEvent, + InstructionLoader, + ProposalScorer, + ProposalScoringInput, + ProposalValidationInput, + ProposalValidator, + ReflectionResult, + Reflector, + ReflectorInput, +} from "./ports.ts"; +export { + type CreateEvolutionArtifactMetadataOptions, + computeEvolutionContentDigest, + createEvolutionArtifactMetadata, +} from "./provenance.ts"; +export { + EvolutionTargetAdapterRegistry, + type EvolutionTargetAdapterRegistryOptions, + type EvolutionTargetKind, + type EvolutionTargetWriter, +} from "./targets.ts"; diff --git a/packages/evolution/src/pipeline.ts b/packages/evolution/src/pipeline.ts new file mode 100644 index 000000000..f29101bcb --- /dev/null +++ b/packages/evolution/src/pipeline.ts @@ -0,0 +1,499 @@ +import type { + ActiveEvolutionStage, + CheckpointSavedLedgerEvent, + EvidenceCorpusManifest, + EvolutionArtifactMetadata, + EvolutionArtifactProducer, + EvolutionCheckpoint, + EvolutionInputSnapshot, + EvolutionJob, + EvolutionLedgerEvent, + EvolutionMemoryProposal, + EvolutionProposal, + EvolutionProposalLedgerDisposition, + EvolutionScope, + FailedCheckpoint, + InstructionSnapshot, + JobCompletedLedgerEvent, + JobCreatedLedgerEvent, + JobFailedLedgerEvent, + MutationBatch, + MutationWriteResult, + MutationWrittenLedgerEvent, + ProposalDispositionLedgerEvent, + ReflectionNode, + ScoredProposal, + ValidationReport, +} from "./contracts.ts"; +import { EVOLUTION_SCHEMA_VERSION } from "./contracts.ts"; +import type { + Curator, + EvolutionArtifact, + EvolutionArtifactStore, + EvolutionClock, + EvolutionEventSink, + EvolutionIdGenerator, + EvolutionJobStore, + EvolutionLedger, + EvolutionModelExecutor, + InstructionLoader, + ProposalScorer, + ProposalValidator, + ReflectionResult, + Reflector, +} from "./ports.ts"; +import { computeEvolutionContentDigest, createEvolutionArtifactMetadata } from "./provenance.ts"; +import type { EvolutionTargetAdapterRegistry } from "./targets.ts"; + +const PIPELINE_PRODUCER_NAME = "diffex-evolution"; + +export interface EvolutionPipelineDependencies { + readonly version: string; + readonly clock: EvolutionClock; + readonly ids: EvolutionIdGenerator; + readonly model: EvolutionModelExecutor; + readonly instructions: InstructionLoader; + readonly reflector: Reflector; + readonly curator: Curator; + readonly validator: ProposalValidator; + readonly scorer: ProposalScorer; + readonly artifacts: EvolutionArtifactStore; + readonly jobs: EvolutionJobStore; + readonly ledger: EvolutionLedger; + readonly events: EvolutionEventSink; + readonly targets: EvolutionTargetAdapterRegistry; +} + +export interface EvolutionRunResult { + readonly job: EvolutionJob; + readonly corpus: EvidenceCorpusManifest; + readonly instructions: InstructionSnapshot; + readonly reflection: ReflectionResult; + readonly proposals: readonly EvolutionProposal[]; + readonly validation: ValidationReport; + readonly scoredProposals: readonly ScoredProposal[]; + readonly mutationBatch: MutationBatch | null; + readonly writeResult: MutationWriteResult | null; +} + +function proposalDisposition(scored: ScoredProposal): EvolutionProposalLedgerDisposition | null { + switch (scored.disposition) { + case "deferred": + case "rejected": + case "superseded": + return scored.disposition; + case "selected": + return null; + } +} + +function assertUniqueIds(values: readonly string[], description: string): void { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) throw new Error(`Duplicate ${description} "${value}"`); + seen.add(value); + } +} + +function assertScoredProposals( + proposals: readonly EvolutionProposal[], + scoredProposals: readonly ScoredProposal[], +): void { + assertUniqueIds( + scoredProposals.map((scored) => scored.proposal.proposalId), + "scored proposal ID", + ); + const proposalsById = new Map(proposals.map((proposal) => [proposal.proposalId, proposal])); + for (const scored of scoredProposals) { + const proposal = proposalsById.get(scored.proposal.proposalId); + if (proposal === undefined) { + throw new Error(`Scorer returned unknown proposal "${scored.proposal.proposalId}"`); + } + if (proposal !== scored.proposal) { + throw new Error(`Scorer replaced proposal "${scored.proposal.proposalId}"`); + } + if (scored.disposition === "selected" && proposal.disposition !== "actionable") { + throw new Error(`Deferred proposal "${proposal.proposalId}" cannot be selected`); + } + } + if (scoredProposals.length !== proposals.length) { + throw new Error("Scorer must return exactly one result for every proposal"); + } +} + +export class EvolutionPipeline { + readonly #dependencies: EvolutionPipelineDependencies; + readonly #producer: EvolutionArtifactProducer; + + constructor(dependencies: EvolutionPipelineDependencies) { + this.#dependencies = dependencies; + this.#producer = { + name: PIPELINE_PRODUCER_NAME, + version: dependencies.version, + promptVersion: null, + model: null, + }; + } + + #metadata( + artifactId: string, + sourceIds: readonly string[], + scope: EvolutionScope, + createdAt: string, + content: unknown, + ): EvolutionArtifactMetadata { + return createEvolutionArtifactMetadata({ + artifactId, + producer: this.#producer, + sourceIds, + scope, + createdAt, + content, + }); + } + + #ledgerMetadata(eventId: string, job: EvolutionJob, createdAt: string, content: unknown): EvolutionArtifactMetadata { + return this.#metadata(eventId, [job.jobId], job.metadata.scope, createdAt, content); + } + + async #appendLedger(event: EvolutionLedgerEvent): Promise { + await this.#dependencies.ledger.append(event); + } + + async #saveCheckpoint(job: EvolutionJob, checkpoint: EvolutionCheckpoint): Promise { + const updatedAt = this.#dependencies.clock.now(); + const status = + checkpoint.stage === "completed" ? "completed" : checkpoint.stage === "failed" ? "failed" : "running"; + const content = { + jobId: job.jobId, + inputSnapshotId: job.inputSnapshotId, + status, + checkpoint, + updatedAt, + }; + const updated: EvolutionJob = { + ...job, + metadata: this.#metadata( + job.jobId, + [job.inputSnapshotId], + job.metadata.scope, + job.metadata.createdAt, + content, + ), + status, + checkpoint, + updatedAt, + }; + await this.#dependencies.jobs.save(updated); + const eventId = this.#dependencies.ids.nextId("ledger-event"); + const event: CheckpointSavedLedgerEvent = { + metadata: this.#ledgerMetadata(eventId, updated, updatedAt, checkpoint), + eventId, + jobId: updated.jobId, + type: "checkpoint-saved", + checkpoint, + }; + await this.#appendLedger(event); + await this.#dependencies.events.emit({ + jobId: updated.jobId, + emittedAt: updatedAt, + type: checkpoint.stage === "failed" ? "failed" : "checkpoint", + checkpoint, + }); + return updated; + } + + async #putArtifacts(artifacts: readonly EvolutionArtifact[]): Promise { + for (const artifact of artifacts) await this.#dependencies.artifacts.put(artifact); + } + + #createCorpus(input: EvolutionInputSnapshot): EvidenceCorpusManifest { + assertUniqueIds( + input.evidenceSources.map((source) => source.sourceId), + "evidence source ID", + ); + const documents = input.evidenceSources + .filter((source) => source.origin === "product-runtime") + .map((source) => { + const actualDigest = computeEvolutionContentDigest(source.invocation); + if (source.contentDigest !== actualDigest) { + throw new Error(`Evidence source "${source.sourceId}" content digest does not match its invocation`); + } + return { + documentId: this.#dependencies.ids.nextId("corpus-document"), + sourceId: source.sourceId, + contentDigest: source.contentDigest, + materializedInvocationVersion: source.invocation.version, + traceId: source.invocation.traceId, + integrity: source.invocation.integrity, + terminationStatus: source.invocation.terminationStatus, + evidenceIds: source.invocation.evidence.map((evidence) => evidence.evidenceId), + }; + }); + const excludedSources = input.evidenceSources + .filter((source) => source.origin === "evolution-pipeline") + .map((source) => ({ sourceId: source.sourceId, reason: "evolution-pipeline-origin" as const })); + const artifactId = this.#dependencies.ids.nextId("corpus-manifest"); + const createdAt = this.#dependencies.clock.now(); + return { + metadata: this.#metadata( + artifactId, + [input.metadata.artifactId, ...documents.map((document) => document.sourceId)], + input.metadata.scope, + createdAt, + { documents, excludedSources }, + ), + documents, + excludedSources, + }; + } + + #createMutationBatch(job: EvolutionJob, selected: readonly ScoredProposal[]): MutationBatch | null { + if (selected.length === 0) return null; + const proposals = selected.map((scored) => scored.proposal as EvolutionMemoryProposal); + const expectedHarnessVersion = proposals[0]?.target.expectedHarnessVersion; + if (expectedHarnessVersion === undefined) return null; + if (proposals.some((proposal) => proposal.target.expectedHarnessVersion !== expectedHarnessVersion)) { + throw new Error("Selected proposals do not share one expected harness version"); + } + const mutations = proposals.map((proposal) => ({ + mutationId: this.#dependencies.ids.nextId("mutation"), + proposalId: proposal.proposalId, + target: proposal.target, + payload: proposal.executablePayload, + })); + const batchId = this.#dependencies.ids.nextId("mutation-batch"); + const createdAt = this.#dependencies.clock.now(); + return { + metadata: this.#metadata( + batchId, + [job.jobId, ...selected.map((scored) => scored.metadata.artifactId)], + job.metadata.scope, + createdAt, + { batchId, targetKind: "evolution-memory", mutations }, + ), + batchId, + targetKind: "evolution-memory", + mutations, + }; + } + + async #recordProposalDisposition( + job: EvolutionJob, + scored: ScoredProposal, + disposition: EvolutionProposalLedgerDisposition, + reasonCodes: readonly string[] = scored.reasonCodes, + ): Promise { + const eventId = this.#dependencies.ids.nextId("ledger-event"); + const createdAt = this.#dependencies.clock.now(); + const content = { proposalId: scored.proposal.proposalId, disposition, reasonCodes }; + const event: ProposalDispositionLedgerEvent = { + metadata: this.#ledgerMetadata(eventId, job, createdAt, content), + eventId, + jobId: job.jobId, + type: "proposal-disposition", + proposalId: scored.proposal.proposalId, + disposition, + reasonCodes, + }; + await this.#appendLedger(event); + } + + async run(input: EvolutionInputSnapshot): Promise { + const jobId = this.#dependencies.ids.nextId("job"); + const createdAt = this.#dependencies.clock.now(); + const initialCheckpoint = { schemaVersion: EVOLUTION_SCHEMA_VERSION, stage: "created" } as const; + const initialContent = { + jobId, + inputSnapshotId: input.metadata.artifactId, + status: "running", + checkpoint: initialCheckpoint, + updatedAt: createdAt, + }; + let job: EvolutionJob = { + metadata: this.#metadata(jobId, [input.metadata.artifactId], input.metadata.scope, createdAt, initialContent), + jobId, + inputSnapshotId: input.metadata.artifactId, + status: "running", + checkpoint: initialCheckpoint, + updatedAt: createdAt, + }; + await this.#dependencies.artifacts.put(input); + await this.#dependencies.jobs.create(job); + const createdEventId = this.#dependencies.ids.nextId("ledger-event"); + const createdEvent: JobCreatedLedgerEvent = { + metadata: this.#ledgerMetadata(createdEventId, job, createdAt, initialContent), + eventId: createdEventId, + jobId, + type: "job-created", + inputSnapshotId: input.metadata.artifactId, + }; + await this.#appendLedger(createdEvent); + await this.#dependencies.events.emit({ + jobId, + emittedAt: createdAt, + type: "started", + checkpoint: initialCheckpoint, + }); + + let activeStage: ActiveEvolutionStage = "created"; + try { + const corpus = this.#createCorpus(input); + const instructions = await this.#dependencies.instructions.load(input); + await this.#putArtifacts([corpus, instructions]); + activeStage = "corpus-prepared"; + job = await this.#saveCheckpoint(job, { + schemaVersion: EVOLUTION_SCHEMA_VERSION, + stage: activeStage, + corpusManifestId: corpus.metadata.artifactId, + instructionSnapshotId: instructions.metadata.artifactId, + }); + + const reflection = await this.#dependencies.reflector.reflect({ + input, + corpus, + instructions, + model: this.#dependencies.model, + }); + await this.#putArtifacts([...reflection.nodes, ...reflection.lessons]); + activeStage = "reflected"; + job = await this.#saveCheckpoint(job, { + schemaVersion: EVOLUTION_SCHEMA_VERSION, + stage: activeStage, + reflectionNodeIds: reflection.nodes.map((node: ReflectionNode) => node.nodeId), + lessonIds: reflection.lessons.map((lesson) => lesson.lessonId), + }); + + const proposals = await this.#dependencies.curator.curate({ + input, + corpus, + instructions, + reflection, + model: this.#dependencies.model, + }); + assertUniqueIds( + proposals.map((proposal) => proposal.proposalId), + "proposal ID", + ); + await this.#putArtifacts(proposals); + activeStage = "curated"; + job = await this.#saveCheckpoint(job, { + schemaVersion: EVOLUTION_SCHEMA_VERSION, + stage: activeStage, + proposalIds: proposals.map((proposal) => proposal.proposalId), + }); + + const validationInput = { input, corpus, instructions, reflection, proposals }; + const validation = await this.#dependencies.validator.validate(validationInput); + await this.#dependencies.artifacts.put(validation); + activeStage = "validated"; + job = await this.#saveCheckpoint(job, { + schemaVersion: EVOLUTION_SCHEMA_VERSION, + stage: activeStage, + validationReportId: validation.metadata.artifactId, + }); + + const scoredProposals = await this.#dependencies.scorer.score({ ...validationInput, validation }); + assertScoredProposals(proposals, scoredProposals); + await this.#putArtifacts(scoredProposals); + activeStage = "scored"; + job = await this.#saveCheckpoint(job, { + schemaVersion: EVOLUTION_SCHEMA_VERSION, + stage: activeStage, + scoredProposalIds: scoredProposals.map((scored) => scored.metadata.artifactId), + }); + for (const scored of scoredProposals) { + const disposition = proposalDisposition(scored); + if (disposition !== null) await this.#recordProposalDisposition(job, scored, disposition); + } + + const selected = scoredProposals.filter((scored) => scored.disposition === "selected"); + const mutationBatch = this.#createMutationBatch(job, selected); + let writeResult: MutationWriteResult | null = null; + if (mutationBatch !== null) { + await this.#dependencies.artifacts.put(mutationBatch); + const writer = this.#dependencies.targets.writerFor(mutationBatch.targetKind); + if (writer === undefined) { + throw new Error(`No enabled writer for evolution target "${mutationBatch.targetKind}"`); + } + writeResult = await writer.write(mutationBatch); + const writeEventId = this.#dependencies.ids.nextId("ledger-event"); + const writeEvent: MutationWrittenLedgerEvent = { + metadata: this.#ledgerMetadata(writeEventId, job, this.#dependencies.clock.now(), writeResult), + eventId: writeEventId, + jobId, + type: "mutation-written", + batchId: mutationBatch.batchId, + writeResult, + }; + await this.#appendLedger(writeEvent); + for (const scored of selected) { + await this.#recordProposalDisposition( + job, + scored, + writeResult.status === "applied" ? "applied" : "failed", + writeResult.status === "applied" ? scored.reasonCodes : ["writer-failed"], + ); + } + } + activeStage = "written"; + job = await this.#saveCheckpoint(job, { + schemaVersion: EVOLUTION_SCHEMA_VERSION, + stage: activeStage, + mutationBatchId: mutationBatch?.batchId ?? null, + writeResult, + }); + if (writeResult?.status === "failed") { + throw new Error(writeResult.message ?? `Mutation batch "${writeResult.batchId}" failed`); + } + + const resultingHarnessVersion = writeResult?.resultingHarnessVersion ?? null; + job = await this.#saveCheckpoint(job, { + schemaVersion: EVOLUTION_SCHEMA_VERSION, + stage: "completed", + resultingHarnessVersion, + }); + const completedEventId = this.#dependencies.ids.nextId("ledger-event"); + const completedEvent: JobCompletedLedgerEvent = { + metadata: this.#ledgerMetadata(completedEventId, job, this.#dependencies.clock.now(), { + resultingHarnessVersion, + }), + eventId: completedEventId, + jobId, + type: "job-completed", + resultingHarnessVersion, + }; + await this.#appendLedger(completedEvent); + return { + job, + corpus, + instructions, + reflection, + proposals, + validation, + scoredProposals, + mutationBatch, + writeResult, + }; + } catch (error) { + const failedCheckpoint: FailedCheckpoint = { + schemaVersion: EVOLUTION_SCHEMA_VERSION, + stage: "failed", + failedStage: activeStage, + message: error instanceof Error ? error.message : String(error), + }; + job = await this.#saveCheckpoint(job, failedCheckpoint); + const failedEventId = this.#dependencies.ids.nextId("ledger-event"); + const failedEvent: JobFailedLedgerEvent = { + metadata: this.#ledgerMetadata(failedEventId, job, this.#dependencies.clock.now(), failedCheckpoint), + eventId: failedEventId, + jobId, + type: "job-failed", + failedStage: activeStage, + message: failedCheckpoint.message, + }; + await this.#appendLedger(failedEvent); + throw error; + } + } +} diff --git a/packages/evolution/src/ports.ts b/packages/evolution/src/ports.ts new file mode 100644 index 000000000..2664a848c --- /dev/null +++ b/packages/evolution/src/ports.ts @@ -0,0 +1,130 @@ +import type { + EvidenceCorpusManifest, + EvolutionCheckpoint, + EvolutionInputSnapshot, + EvolutionJob, + EvolutionLedgerEvent, + EvolutionProposal, + InstructionSnapshot, + JsonObject, + Lesson, + MutationBatch, + ReflectionNode, + ScoredProposal, + ValidationReport, +} from "./contracts.ts"; + +export type EvolutionArtifact = + | EvidenceCorpusManifest + | EvolutionInputSnapshot + | EvolutionProposal + | InstructionSnapshot + | Lesson + | MutationBatch + | ReflectionNode + | ScoredProposal + | ValidationReport; + +export interface EvolutionClock { + now(): string; +} + +export interface EvolutionIdGenerator { + nextId(kind: string): string; +} + +export interface EvolutionModelRequest { + readonly stage: "curator" | "reflector"; + readonly promptVersion: string; + readonly input: JsonObject; +} + +export interface EvolutionModelResponse { + readonly output: JsonObject; + readonly model: { + readonly provider: string; + readonly model: string; + }; +} + +export interface EvolutionModelExecutor { + execute(request: EvolutionModelRequest): Promise; +} + +export interface InstructionLoader { + load(input: EvolutionInputSnapshot): Promise; +} + +export interface ReflectorInput { + readonly input: EvolutionInputSnapshot; + readonly corpus: EvidenceCorpusManifest; + readonly instructions: InstructionSnapshot; + readonly model: EvolutionModelExecutor; +} + +export interface ReflectionResult { + readonly nodes: readonly ReflectionNode[]; + readonly lessons: readonly Lesson[]; +} + +export interface Reflector { + reflect(input: ReflectorInput): Promise; +} + +export interface CuratorInput { + readonly input: EvolutionInputSnapshot; + readonly corpus: EvidenceCorpusManifest; + readonly instructions: InstructionSnapshot; + readonly reflection: ReflectionResult; + readonly model: EvolutionModelExecutor; +} + +export interface Curator { + curate(input: CuratorInput): Promise; +} + +export interface ProposalValidationInput { + readonly input: EvolutionInputSnapshot; + readonly corpus: EvidenceCorpusManifest; + readonly instructions: InstructionSnapshot; + readonly reflection: ReflectionResult; + readonly proposals: readonly EvolutionProposal[]; +} + +export interface ProposalValidator { + validate(input: ProposalValidationInput): Promise; +} + +export interface ProposalScoringInput extends ProposalValidationInput { + readonly validation: ValidationReport; +} + +export interface ProposalScorer { + score(input: ProposalScoringInput): Promise; +} + +export interface EvolutionArtifactStore { + put(artifact: EvolutionArtifact): Promise; +} + +export interface EvolutionJobStore { + create(job: EvolutionJob): Promise; + save(job: EvolutionJob): Promise; + load(jobId: string): Promise; +} + +export interface EvolutionLedger { + append(event: EvolutionLedgerEvent): Promise; + read(jobId: string): Promise; +} + +export interface EvolutionProgressEvent { + readonly jobId: string; + readonly emittedAt: string; + readonly type: "checkpoint" | "failed" | "started"; + readonly checkpoint: EvolutionCheckpoint; +} + +export interface EvolutionEventSink { + emit(event: EvolutionProgressEvent): void | Promise; +} diff --git a/packages/evolution/src/provenance.ts b/packages/evolution/src/provenance.ts new file mode 100644 index 000000000..610c1e32f --- /dev/null +++ b/packages/evolution/src/provenance.ts @@ -0,0 +1,53 @@ +import { createHash } from "node:crypto"; +import type { EvolutionArtifactMetadata, EvolutionArtifactProducer, EvolutionScope, JsonValue } from "./contracts.ts"; +import { EVOLUTION_SCHEMA_VERSION } from "./contracts.ts"; + +const CONTENT_DIGEST_PREFIX = "sha256:"; + +function canonicalize(value: unknown, path: string): JsonValue { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error(`Cannot digest non-finite number at ${path}`); + return value; + } + if (Array.isArray(value)) return value.map((item, index) => canonicalize(item, `${path}[${index}]`)); + if (typeof value === "object") { + const record = value as Readonly>; + const result: Record = {}; + for (const key of Object.keys(record).sort()) { + const child = record[key]; + if (child === undefined) throw new Error(`Cannot digest undefined value at ${path}.${key}`); + result[key] = canonicalize(child, `${path}.${key}`); + } + return result; + } + throw new Error(`Cannot digest ${typeof value} value at ${path}`); +} + +export function computeEvolutionContentDigest(value: unknown): string { + const canonical = JSON.stringify(canonicalize(value, "$")); + return `${CONTENT_DIGEST_PREFIX}${createHash("sha256").update(canonical).digest("hex")}`; +} + +export interface CreateEvolutionArtifactMetadataOptions { + readonly artifactId: string; + readonly producer: EvolutionArtifactProducer; + readonly sourceIds: readonly string[]; + readonly scope: EvolutionScope; + readonly createdAt: string; + readonly content: unknown; +} + +export function createEvolutionArtifactMetadata( + options: CreateEvolutionArtifactMetadataOptions, +): EvolutionArtifactMetadata { + return { + schemaVersion: EVOLUTION_SCHEMA_VERSION, + artifactId: options.artifactId, + producer: options.producer, + sourceIds: [...options.sourceIds], + contentDigest: computeEvolutionContentDigest(options.content), + scope: options.scope, + createdAt: options.createdAt, + }; +} diff --git a/packages/evolution/src/targets.ts b/packages/evolution/src/targets.ts new file mode 100644 index 000000000..f03ba227e --- /dev/null +++ b/packages/evolution/src/targets.ts @@ -0,0 +1,42 @@ +import type { EvolutionTarget, MutationBatch, MutationWriteResult } from "./contracts.ts"; + +export type EvolutionTargetKind = EvolutionTarget["kind"]; + +export interface EvolutionTargetWriter { + readonly kind: EvolutionTargetKind; + write(batch: MutationBatch): Promise; +} + +export interface EvolutionTargetAdapterRegistryOptions { + readonly enabledTargetKinds?: readonly EvolutionTargetKind[]; + readonly writers?: readonly EvolutionTargetWriter[]; +} + +export class EvolutionTargetAdapterRegistry { + readonly #enabledTargetKinds: ReadonlySet; + readonly #writers = new Map(); + + constructor(options: EvolutionTargetAdapterRegistryOptions = {}) { + this.#enabledTargetKinds = new Set(options.enabledTargetKinds ?? ["evolution-memory"]); + for (const writer of options.writers ?? []) this.register(writer); + } + + register(writer: EvolutionTargetWriter): void { + if (!this.#enabledTargetKinds.has(writer.kind)) { + throw new Error(`Evolution target "${writer.kind}" is not enabled`); + } + if (this.#writers.has(writer.kind)) { + throw new Error(`Evolution target writer "${writer.kind}" is already registered`); + } + this.#writers.set(writer.kind, writer); + } + + isEnabled(kind: EvolutionTargetKind): boolean { + return this.#enabledTargetKinds.has(kind); + } + + writerFor(kind: EvolutionTargetKind): EvolutionTargetWriter | undefined { + if (!this.isEnabled(kind)) return undefined; + return this.#writers.get(kind); + } +} diff --git a/packages/evolution/test/contracts.test.ts b/packages/evolution/test/contracts.test.ts new file mode 100644 index 000000000..d21c595f4 --- /dev/null +++ b/packages/evolution/test/contracts.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + computeEvolutionContentDigest, + createEvolutionArtifactMetadata, + type EvolutionArtifactProducer, + type EvolutionScope, +} from "../src/index.ts"; + +const scope: EvolutionScope = { repositoryId: "diffex", scopeId: "repository" }; +const producer: EvolutionArtifactProducer = { + name: "diffex-evolution", + version: "1.0.0", + promptVersion: null, + model: null, +}; + +describe("evolution artifact contracts", () => { + it("computes stable content digests independent of object key order", () => { + const first = computeEvolutionContentDigest({ nested: { second: 2, first: 1 }, value: "same" }); + const second = computeEvolutionContentDigest({ value: "same", nested: { first: 1, second: 2 } }); + + expect(first).toBe(second); + expect(first).toMatch(/^sha256:[0-9a-f]{64}$/); + }); + + it("rejects values that cannot be represented by durable JSON", () => { + expect(() => computeEvolutionContentDigest({ missing: undefined })).toThrow("Cannot digest undefined value"); + expect(() => computeEvolutionContentDigest({ invalid: Number.POSITIVE_INFINITY })).toThrow( + "Cannot digest non-finite number", + ); + }); + + it("records explicit provenance for deterministic artifacts", () => { + const metadata = createEvolutionArtifactMetadata({ + artifactId: "artifact-1", + producer, + sourceIds: ["evidence-1"], + scope, + createdAt: "2026-08-19T12:00:00.000Z", + content: { lesson: "Keep exact source references" }, + }); + + expect(metadata).toEqual({ + schemaVersion: 1, + artifactId: "artifact-1", + producer, + sourceIds: ["evidence-1"], + contentDigest: computeEvolutionContentDigest({ lesson: "Keep exact source references" }), + scope, + createdAt: "2026-08-19T12:00:00.000Z", + }); + }); +}); diff --git a/packages/evolution/test/pipeline.test.ts b/packages/evolution/test/pipeline.test.ts new file mode 100644 index 000000000..cebd9501e --- /dev/null +++ b/packages/evolution/test/pipeline.test.ts @@ -0,0 +1,441 @@ +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { MaterializedInvocation } from "@earendil-works/diffex-harness-trace"; +import { describe, expect, it } from "vitest"; +import { + computeEvolutionContentDigest, + createEvolutionArtifactMetadata, + type EvolutionArtifact, + type EvolutionArtifactMetadata, + type EvolutionArtifactProducer, + type EvolutionEventSink, + type EvolutionInputSnapshot, + type EvolutionJob, + type EvolutionLedgerEvent, + type EvolutionMemoryProposal, + type EvolutionModelRequest, + EvolutionPipeline, + type EvolutionProgressEvent, + type EvolutionProposal, + type EvolutionScope, + EvolutionTargetAdapterRegistry, + type EvolutionTargetWriter, + type InstructionSnapshot, + type Lesson, + type MutationBatch, + type MutationWriteResult, + type ReflectionNode, + type ScoredProposal, + type ValidationReport, +} from "../src/index.ts"; + +const FIRST_HARNESS_VERSION = `sha256:${"1".repeat(64)}`; +const SECOND_HARNESS_VERSION = `sha256:${"2".repeat(64)}`; +const CREATED_AT = "2026-08-19T12:00:00.000Z"; +const scope: EvolutionScope = { repositoryId: "diffex", scopeId: "repository" }; +const pipelineProducer: EvolutionArtifactProducer = { + name: "diffex-evolution", + version: "1.0.0", + promptVersion: null, + model: null, +}; +const reflectorProducer: EvolutionArtifactProducer = { + name: "reflector", + version: "1.0.0", + promptVersion: "reflector-v1", + model: { provider: "faux", model: "fixture", configurationDigest: `sha256:${"3".repeat(64)}` }, +}; +const curatorProducer: EvolutionArtifactProducer = { + name: "curator", + version: "1.0.0", + promptVersion: "curator-v1", + model: { provider: "faux", model: "fixture", configurationDigest: `sha256:${"3".repeat(64)}` }, +}; + +function metadata( + artifactId: string, + producer: EvolutionArtifactProducer, + sourceIds: readonly string[], + content: unknown, +): EvolutionArtifactMetadata { + return createEvolutionArtifactMetadata({ artifactId, producer, sourceIds, scope, createdAt: CREATED_AT, content }); +} + +function invocation(traceId: string): MaterializedInvocation { + return { + version: 1, + tracePath: `/traces/${traceId}.jsonl`, + traceId, + producer: null, + integrity: "complete", + terminationStatus: "completed", + health: null, + ordering: { + criteria: ["trace_sequence", "session_cursor", "timestamp", "span_parentage", "causal_id"], + ambiguity: [], + }, + evidence: [], + diagnostics: [], + }; +} + +function inputSnapshot(): EvolutionInputSnapshot { + const productInvocation = invocation("product-trace"); + const evolutionInvocation = invocation("evolution-trace"); + const content = { + harnessVersion: FIRST_HARNESS_VERSION, + evidenceSourceIds: ["source-product", "source-evolution"], + }; + return { + metadata: metadata("input-1", pipelineProducer, ["source-product", "source-evolution"], content), + harness: { + harnessVersion: FIRST_HARNESS_VERSION, + baseVersion: null, + memory: "# Evolution memory\n", + memoryContentDigest: computeEvolutionContentDigest("# Evolution memory\n"), + }, + evidenceSources: [ + { + sourceId: "source-product", + origin: "product-runtime", + contentDigest: computeEvolutionContentDigest(productInvocation), + invocation: productInvocation, + }, + { + sourceId: "source-evolution", + origin: "evolution-pipeline", + contentDigest: computeEvolutionContentDigest(evolutionInvocation), + invocation: evolutionInvocation, + }, + ], + }; +} + +class SequenceIds { + #next = 1; + + nextId(kind: string): string { + const value = `${kind}-${this.#next}`; + this.#next += 1; + return value; + } +} + +class FixedClock { + now(): string { + return CREATED_AT; + } +} + +interface RecordingState { + readonly modelStages: EvolutionModelRequest["stage"][]; + readonly artifacts: EvolutionArtifact[]; + readonly createdJobs: EvolutionJob[]; + readonly savedJobs: EvolutionJob[]; + readonly ledger: EvolutionLedgerEvent[]; + readonly progress: EvolutionProgressEvent[]; + readonly writtenBatches: MutationBatch[]; +} + +function instructionSnapshot(input: EvolutionInputSnapshot): InstructionSnapshot { + const documents = [ + { + path: "/repo/AGENTS.md", + precedence: 1, + content: "Preserve source provenance.", + contentDigest: computeEvolutionContentDigest("Preserve source provenance."), + }, + ]; + return { + metadata: metadata("instructions-1", pipelineProducer, [input.metadata.artifactId], documents), + documents, + }; +} + +function reflectionArtifacts(): { readonly node: ReflectionNode; readonly lesson: Lesson } { + const lesson: Lesson = { + metadata: metadata("lesson-1", reflectorProducer, ["source-product"], { summary: "Keep narrow deltas" }), + lessonId: "lesson-1", + kind: "strategy", + summary: "Keep narrow deltas", + evidenceIds: [], + counterevidenceIds: [], + scope, + uncertainty: null, + }; + const node: ReflectionNode = { + metadata: metadata("reflection-1", reflectorProducer, ["source-product"], { lessonIds: [lesson.lessonId] }), + nodeId: "reflection-1", + parentNodeId: null, + childNodeIds: [], + evidenceIds: [], + lessonIds: [lesson.lessonId], + status: "complete", + }; + return { node, lesson }; +} + +function proposals(): readonly EvolutionProposal[] { + const actionable = (proposalId: string, payload: string): EvolutionMemoryProposal => ({ + metadata: metadata(proposalId, curatorProducer, ["lesson-1"], { payload }), + proposalId, + summary: payload, + lessonIds: ["lesson-1"], + evidenceIds: [], + disposition: "actionable", + target: { kind: "evolution-memory", expectedHarnessVersion: FIRST_HARNESS_VERSION }, + executablePayload: { content: payload }, + }); + return [ + actionable("proposal-applied", "Append one focused memory entry"), + { + metadata: metadata("proposal-deferred", curatorProducer, ["lesson-1"], { finding: "Improve a skill" }), + proposalId: "proposal-deferred", + summary: "Improve a skill", + lessonIds: ["lesson-1"], + evidenceIds: [], + disposition: "deferred", + target: { kind: "skill", skillName: "review" }, + finding: "A future skill adapter could apply this finding", + }, + actionable("proposal-rejected", "Unverified memory entry"), + ]; +} + +function validationReport(inputProposals: readonly EvolutionProposal[]): ValidationReport { + const proposalResults = inputProposals.map((proposal) => ({ + proposalId: proposal.proposalId, + disposition: + proposal.proposalId === "proposal-applied" + ? ("accepted" as const) + : proposal.disposition === "deferred" + ? ("deferred" as const) + : ("rejected" as const), + issues: [], + })); + return { + metadata: metadata( + "validation-1", + pipelineProducer, + inputProposals.map((proposal) => proposal.proposalId), + { + proposalResults, + }, + ), + proposalResults, + }; +} + +function scoredProposals(inputProposals: readonly EvolutionProposal[]): readonly ScoredProposal[] { + return inputProposals.map((proposal) => { + const disposition = + proposal.proposalId === "proposal-applied" + ? ("selected" as const) + : proposal.disposition === "deferred" + ? ("deferred" as const) + : ("rejected" as const); + return { + metadata: metadata(`score-${proposal.proposalId}`, pipelineProducer, [proposal.proposalId], { disposition }), + proposal, + score: disposition === "selected" ? 80 : null, + disposition, + reasonCodes: [disposition], + }; + }); +} + +function recordingPipeline(writeStatus: MutationWriteResult["status"] = "applied"): { + readonly pipeline: EvolutionPipeline; + readonly state: RecordingState; +} { + const state: RecordingState = { + modelStages: [], + artifacts: [], + createdJobs: [], + savedJobs: [], + ledger: [], + progress: [], + writtenBatches: [], + }; + const writer: EvolutionTargetWriter = { + kind: "evolution-memory", + async write(batch) { + state.writtenBatches.push(batch); + return { + batchId: batch.batchId, + status: writeStatus, + resultingHarnessVersion: writeStatus === "applied" ? SECOND_HARNESS_VERSION : null, + message: writeStatus === "failed" ? "fixture writer failure" : null, + }; + }, + }; + const events: EvolutionEventSink = { + emit(event) { + state.progress.push(event); + }, + }; + const pipeline = new EvolutionPipeline({ + version: "1.0.0", + clock: new FixedClock(), + ids: new SequenceIds(), + model: { + async execute(request) { + state.modelStages.push(request.stage); + return { output: { accepted: true }, model: { provider: "faux", model: "fixture" } }; + }, + }, + instructions: { + async load(input) { + return instructionSnapshot(input); + }, + }, + reflector: { + async reflect(input) { + await input.model.execute({ stage: "reflector", promptVersion: "reflector-v1", input: {} }); + const { node, lesson } = reflectionArtifacts(); + return { nodes: [node], lessons: [lesson] }; + }, + }, + curator: { + async curate(input) { + await input.model.execute({ stage: "curator", promptVersion: "curator-v1", input: {} }); + return proposals(); + }, + }, + validator: { + async validate(input) { + return validationReport(input.proposals); + }, + }, + scorer: { + async score(input) { + return scoredProposals(input.proposals); + }, + }, + artifacts: { + async put(artifact) { + state.artifacts.push(artifact); + }, + }, + jobs: { + async create(job) { + state.createdJobs.push(job); + }, + async save(job) { + state.savedJobs.push(job); + }, + async load(jobId) { + return [...state.createdJobs, ...state.savedJobs].filter((job) => job.jobId === jobId).at(-1) ?? null; + }, + }, + ledger: { + async append(event) { + state.ledger.push(event); + }, + async read(jobId) { + return state.ledger.filter((event) => event.jobId === jobId); + }, + }, + events, + targets: new EvolutionTargetAdapterRegistry({ writers: [writer] }), + }); + return { pipeline, state }; +} + +describe("evolution pipeline", () => { + it("orchestrates isolated stages and writes only enabled memory proposals", async () => { + const { pipeline, state } = recordingPipeline(); + const result = await pipeline.run(inputSnapshot()); + + expect(state.modelStages).toEqual(["reflector", "curator"]); + expect(state.savedJobs.map((job) => job.checkpoint.stage)).toEqual([ + "corpus-prepared", + "reflected", + "curated", + "validated", + "scored", + "written", + "completed", + ]); + expect(result.corpus.documents.map((document) => document.sourceId)).toEqual(["source-product"]); + expect(result.corpus.excludedSources).toEqual([ + { sourceId: "source-evolution", reason: "evolution-pipeline-origin" }, + ]); + expect(state.writtenBatches).toHaveLength(1); + expect(state.writtenBatches[0]?.mutations.map((mutation) => mutation.proposalId)).toEqual(["proposal-applied"]); + expect(result.job.status).toBe("completed"); + expect(result.job.checkpoint).toEqual({ + schemaVersion: 1, + stage: "completed", + resultingHarnessVersion: SECOND_HARNESS_VERSION, + }); + + const dispositions = state.ledger + .filter((event) => event.type === "proposal-disposition") + .map((event) => [event.proposalId, event.disposition]); + expect(dispositions).toEqual([ + ["proposal-deferred", "deferred"], + ["proposal-rejected", "rejected"], + ["proposal-applied", "applied"], + ]); + expect(state.artifacts.map((artifact) => artifact.metadata.artifactId)).toContain("input-1"); + expect(state.artifacts.map((artifact) => artifact.metadata.artifactId)).toContain("lesson-1"); + expect(state.artifacts.map((artifact) => artifact.metadata.artifactId)).toContain("proposal-applied"); + }); + + it("records writer failure without converting deferred findings into mutations", async () => { + const { pipeline, state } = recordingPipeline("failed"); + + await expect(pipeline.run(inputSnapshot())).rejects.toThrow("fixture writer failure"); + expect(state.savedJobs.at(-1)?.checkpoint).toMatchObject({ stage: "failed", failedStage: "written" }); + expect(state.writtenBatches[0]?.mutations.map((mutation) => mutation.proposalId)).toEqual(["proposal-applied"]); + expect( + state.ledger + .filter((event) => event.type === "proposal-disposition") + .find((event) => event.proposalId === "proposal-applied"), + ).toMatchObject({ disposition: "failed", reasonCodes: ["writer-failed"] }); + expect(state.ledger.at(-1)).toMatchObject({ type: "job-failed", failedStage: "written" }); + }); + + it("produces identical artifacts for identical inputs and injected determinism", async () => { + const first = await recordingPipeline().pipeline.run(inputSnapshot()); + const second = await recordingPipeline().pipeline.run(inputSnapshot()); + + expect(first.corpus).toEqual(second.corpus); + expect(first.mutationBatch).toEqual(second.mutationBatch); + expect(first.job).toEqual(second.job); + }); + + it("keeps non-memory writers disabled by default and rejects duplicates", () => { + const memoryWriter: EvolutionTargetWriter = { + kind: "evolution-memory", + async write(batch) { + return { batchId: batch.batchId, status: "applied", resultingHarnessVersion: null, message: null }; + }, + }; + const registry = new EvolutionTargetAdapterRegistry({ writers: [memoryWriter] }); + + expect(registry.isEnabled("evolution-memory")).toBe(true); + expect(registry.isEnabled("skill")).toBe(false); + expect(() => registry.register(memoryWriter)).toThrow("already registered"); + expect(() => + registry.register({ + kind: "skill", + async write(batch) { + return { batchId: batch.batchId, status: "applied", resultingHarnessVersion: null, message: null }; + }, + }), + ).toThrow('Evolution target "skill" is not enabled'); + }); + + it("does not import product-specific runtime packages", async () => { + const sourceDirectory = join(dirname(fileURLToPath(import.meta.url)), "../src"); + const sourceFiles = ["contracts.ts", "index.ts", "pipeline.ts", "ports.ts", "provenance.ts", "targets.ts"]; + const source = (await Promise.all(sourceFiles.map((file) => readFile(join(sourceDirectory, file), "utf8")))).join( + "\n", + ); + + expect(source).not.toMatch(/from ["'][^"']*(?:coding-agent|diffex-tui|agent-core|session|extension)[^"']*["']/); + }); +}); diff --git a/packages/evolution/tsconfig.build.json b/packages/evolution/tsconfig.build.json new file mode 100644 index 000000000..695dd9ad2 --- /dev/null +++ b/packages/evolution/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] +} diff --git a/packages/evolution/tsconfig.test.json b/packages/evolution/tsconfig.test.json new file mode 100644 index 000000000..e1276ec0b --- /dev/null +++ b/packages/evolution/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node", "vitest"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/evolution/vitest.config.ts b/packages/evolution/vitest.config.ts new file mode 100644 index 000000000..157f467a0 --- /dev/null +++ b/packages/evolution/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"], + }, +}); diff --git a/tsconfig.json b/tsconfig.json index b44378218..53fe022ba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "paths": { "*": ["./*"], "#harness-trace": ["./packages/harness-trace/src/index.ts"], + "@earendil-works/diffex-evolution": ["./packages/evolution/src/index.ts"], "@earendil-works/diffex-harness-state": ["./packages/harness-state/src/index.ts"], "@earendil-works/diffex-harness-trace": ["./packages/harness-trace/src/index.ts"], "@earendil-works/diffex-telemetry": ["./packages/telemetry/src/index.ts"],