import { createHash } from 'node:crypto'; import { stableCinemaJson } from './cinema-shot-compiler.js'; import type { CinemaSha256 } from './cinema-types.js'; export type CinemaScopeLevel = 'three-shot-proof' | 'proof-scene' | 'short-film' | 'feature'; export interface CinemaScopeMetrics { plannedShots: number; acceptedShots: number; runtimeSeconds: number; identityPassRate: number; continuityPassRate: number; performancePassRate: number; editorialPassRate: number; recoveryDrillsPassed: number; duplicateSpendIncidents: number; authorizedCost: number; actualCost: number; deliveryVerified: boolean; archiveRestoreVerified: boolean; humanApprovalEvidenceIds: string[]; unresolvedBlockers: string[]; } export interface CinemaScopeThresholds { minimumAcceptedShots: number; minimumRuntimeSeconds: number; minimumIdentityPassRate: number; minimumContinuityPassRate: number; minimumPerformancePassRate: number; minimumEditorialPassRate: number; minimumRecoveryDrills: number; requireDelivery: boolean; requireArchiveRestore: boolean; minimumHumanApprovals: number; } export interface CinemaScopeReportArtifact { schemaVersion: 1; projectSlug: string; reportId: string; generatedAt: string; currentScope: CinemaScopeLevel; proposedScope: CinemaScopeLevel | null; metrics: CinemaScopeMetrics; thresholds: CinemaScopeThresholds | null; verdict: 'qualified' | 'blocked' | 'terminal-scope'; passedChecks: string[]; blockers: string[]; evidenceIds: string[]; contentHash: CinemaSha256; } const NEXT_SCOPE: Record = { 'three-shot-proof': 'proof-scene', 'proof-scene': 'short-film', 'short-film': 'feature', feature: null, }; const THRESHOLDS: Record, CinemaScopeThresholds> = { 'three-shot-proof': { minimumAcceptedShots: 3, minimumRuntimeSeconds: 18, minimumIdentityPassRate: 1, minimumContinuityPassRate: 1, minimumPerformancePassRate: 1, minimumEditorialPassRate: 1, minimumRecoveryDrills: 3, requireDelivery: true, requireArchiveRestore: true, minimumHumanApprovals: 3 }, 'proof-scene': { minimumAcceptedShots: 5, minimumRuntimeSeconds: 30, minimumIdentityPassRate: 0.98, minimumContinuityPassRate: 0.98, minimumPerformancePassRate: 0.95, minimumEditorialPassRate: 0.98, minimumRecoveryDrills: 5, requireDelivery: true, requireArchiveRestore: true, minimumHumanApprovals: 5 }, 'short-film': { minimumAcceptedShots: 20, minimumRuntimeSeconds: 300, minimumIdentityPassRate: 0.99, minimumContinuityPassRate: 0.99, minimumPerformancePassRate: 0.98, minimumEditorialPassRate: 0.99, minimumRecoveryDrills: 8, requireDelivery: true, requireArchiveRestore: true, minimumHumanApprovals: 20 }, }; function hash(value: unknown): CinemaSha256 { return `sha256:${createHash('sha256').update(stableCinemaJson(value)).digest('hex')}`; } export function evaluateCinemaScope(input: { projectSlug: string; reportId: string; generatedAt: string; currentScope: CinemaScopeLevel; metrics: CinemaScopeMetrics; evidenceIds: string[]; }): CinemaScopeReportArtifact { const proposedScope = NEXT_SCOPE[input.currentScope]; const thresholds = input.currentScope === 'feature' ? null : THRESHOLDS[input.currentScope]; const passedChecks: string[] = []; const blockers = [...input.metrics.unresolvedBlockers]; const check = (name: string, ok: boolean, failure: string): void => { if (ok) passedChecks.push(name); else blockers.push(failure); }; if (thresholds) { check('accepted-shot-count', input.metrics.acceptedShots >= thresholds.minimumAcceptedShots && input.metrics.acceptedShots === input.metrics.plannedShots, `accepted ${input.metrics.acceptedShots}/${input.metrics.plannedShots} shots; need all planned and at least ${thresholds.minimumAcceptedShots}`); check('runtime', input.metrics.runtimeSeconds >= thresholds.minimumRuntimeSeconds, `runtime ${input.metrics.runtimeSeconds}s is below ${thresholds.minimumRuntimeSeconds}s`); for (const lane of ['identity', 'continuity', 'performance', 'editorial'] as const) { const metric = input.metrics[`${lane}PassRate`]; const required = thresholds[`minimum${lane[0].toUpperCase()}${lane.slice(1)}PassRate` as keyof CinemaScopeThresholds] as number; check(`${lane}-pass-rate`, metric >= required, `${lane} pass rate ${metric.toFixed(3)} is below ${required.toFixed(3)}`); } check('recovery-drills', input.metrics.recoveryDrillsPassed >= thresholds.minimumRecoveryDrills, `recovery drills ${input.metrics.recoveryDrillsPassed}/${thresholds.minimumRecoveryDrills}`); check('zero-duplicate-spend', input.metrics.duplicateSpendIncidents === 0, `${input.metrics.duplicateSpendIncidents} duplicate-spend incident(s)`); check('within-authorization', input.metrics.actualCost <= input.metrics.authorizedCost, `actual cost ${input.metrics.actualCost} exceeds authorized ${input.metrics.authorizedCost}`); check('delivery', !thresholds.requireDelivery || input.metrics.deliveryVerified, 'delivery verification is missing'); check('archive-restore', !thresholds.requireArchiveRestore || input.metrics.archiveRestoreVerified, 'archive/restore verification is missing'); check('human-approvals', input.metrics.humanApprovalEvidenceIds.length >= thresholds.minimumHumanApprovals, `human approvals ${input.metrics.humanApprovalEvidenceIds.length}/${thresholds.minimumHumanApprovals}`); } const deduplicatedBlockers = [...new Set(blockers)]; const canonical = { schemaVersion: 1 as const, projectSlug: input.projectSlug, reportId: input.reportId, generatedAt: input.generatedAt, currentScope: input.currentScope, proposedScope, metrics: structuredClone(input.metrics), thresholds, verdict: proposedScope === null ? 'terminal-scope' as const : deduplicatedBlockers.length === 0 ? 'qualified' as const : 'blocked' as const, passedChecks, blockers: deduplicatedBlockers, evidenceIds: [...new Set(input.evidenceIds)], }; return { ...canonical, contentHash: hash(canonical) }; } export function validateCinemaScopeReport(report: CinemaScopeReportArtifact): string[] { const issues: string[] = []; if (!report.projectSlug.trim() || !report.reportId.trim()) issues.push('projectSlug and reportId are required'); if (Number.isNaN(Date.parse(report.generatedAt))) issues.push('generatedAt must be an ISO timestamp'); for (const key of ['identityPassRate', 'continuityPassRate', 'performancePassRate', 'editorialPassRate'] as const) if (report.metrics[key] < 0 || report.metrics[key] > 1) issues.push(`${key} must be between 0 and 1`); if (report.metrics.actualCost < 0 || report.metrics.authorizedCost < 0) issues.push('cost metrics must be non-negative'); if (report.verdict === 'qualified' && (report.proposedScope === null || report.blockers.length > 0)) issues.push('qualified report cannot have blockers or lack a proposed scope'); if (report.verdict === 'blocked' && report.blockers.length === 0) issues.push('blocked report requires blockers'); const { contentHash: _contentHash, ...canonical } = report; if (report.contentHash !== hash(canonical)) issues.push('contentHash does not match canonical scope report'); return issues; }