mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 15:46:30 +03:00
Expand concept system: modes, ratings, re-roll, breadth strategy
Catalog: mode-aligned staging surfaces (persuade/operate/read/experience), star ratings on approvals feeding challenger draw weights, family retirements, authoring strategy and territory guide, rework and breadth authoring rounds, composition mining from rejected worlds. Seed: six challengers (two per tier), --reroll chains, --mode staging filter, rating-weighted draws. New-work: Present/visualize/re-roll flow, image-gen requirement, register-neutral vocabulary. Pipeline: per-mode staging prompts with split frames, hero-from-board reference generation, render-safety guards. Labs: ratings UI, unrated filter, mode chips, composition approve-guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
144cee5c36
commit
7557935fdb
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+7036
-23280
File diff suppressed because it is too large
Load Diff
+1859
-229
File diff suppressed because it is too large
Load Diff
+206
-34
@@ -16,15 +16,29 @@
|
||||
* - PROMOTED INDEX: which entry of the model's own resonance-ordered
|
||||
* shortlist must be taken seriously beside its favorites. The dice never
|
||||
* choose an ungrounded ingredient; they only refuse the argmax rut.
|
||||
* - CHALLENGERS (3): outside forms from concept-ingredients.json, weighed
|
||||
* against the derived candidates on exactly two axes — audience
|
||||
* identification and product clarity. They win only when they beat the
|
||||
* - CHALLENGERS (6): outside forms from concept-ingredients.json, two from
|
||||
* each challenger tier (graphic system, instrument language, atmosphere
|
||||
* world), weighed
|
||||
* against the derived candidates on audience identification, product
|
||||
* clarity, system leverage, and use of the medium. They win only when they beat the
|
||||
* grounded list; measured behavior is that they lose to strong cultural
|
||||
* material and win over thin categories, which is the intended shape.
|
||||
* - RE-ROLL (--reroll <n>): round n of the same base key. The script
|
||||
* recomputes what rounds 0..n-1 drew, excludes all of it, and rolls a
|
||||
* fresh promoted index, challengers, and staging. One base key therefore
|
||||
* reproduces the entire chain of rounds.
|
||||
* - RATINGS: the reviewer's approval ratings weight the challenger draw
|
||||
* (3-star doubles the odds, 1-star sits out); the approved pool itself
|
||||
* is unchanged.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/concept-seed.mjs --scope direction
|
||||
* node scripts/concept-seed.mjs --scope surface --from <key>
|
||||
* node scripts/concept-seed.mjs --scope direction --mode persuade
|
||||
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
|
||||
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
|
||||
*
|
||||
* --mode names the requested surface's mode (persuade, operate, read,
|
||||
* experience) so the appended staging matches its register of work; omitted,
|
||||
* the staging rolls from the full approved pool.
|
||||
*
|
||||
* Env vars:
|
||||
* IMPECCABLE_CONCEPT_SEED — same as --from; for reproducible eval runs.
|
||||
@@ -37,33 +51,144 @@ import {
|
||||
approvedPoolRevision,
|
||||
deterministicRank,
|
||||
readConceptCatalog,
|
||||
validateConceptCatalog,
|
||||
WELL_TIERS,
|
||||
} from './lib/concept-catalog.mjs';
|
||||
import { readCompositionCatalog } from './lib/composition-catalog.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const { concepts } = readConceptCatalog(
|
||||
const catalogState = readConceptCatalog(
|
||||
join(here, 'concept-ingredients.json'),
|
||||
join(here, 'concept-reviews.json')
|
||||
);
|
||||
const catalogValidation = validateConceptCatalog(catalogState.catalog, catalogState.reviewData);
|
||||
if (catalogValidation.errors.length > 0) {
|
||||
throw new Error(`concept-seed: invalid catalog: ${catalogValidation.errors.join('; ')}`);
|
||||
}
|
||||
const { concepts } = catalogState;
|
||||
const compositionState = readCompositionCatalog(
|
||||
join(here, 'composition-ingredients.json'),
|
||||
join(here, 'composition-reviews.json')
|
||||
);
|
||||
const compositions = compositionState.compositions;
|
||||
|
||||
export function selectApprovedChallengers({ scope, key, sourceConcepts = concepts }) {
|
||||
export function renderChallenger(concept, index) {
|
||||
const system = concept.system.map(rule => ` - ${rule}`).join('\n');
|
||||
return ` ${index + 1}. ${concept.form}
|
||||
CREATIVE SPARK: ${concept.spark}
|
||||
SYSTEM GRAMMAR:
|
||||
${system}
|
||||
WEB LEVERAGE: ${concept.webLeverage}`;
|
||||
}
|
||||
|
||||
export function renderStaging(composition) {
|
||||
const grammar = composition.grammar.map(rule => ` - ${rule}`).join('\n');
|
||||
return ` ${composition.form}
|
||||
SPARK: ${composition.spark}
|
||||
STAGING GRAMMAR:
|
||||
${grammar}
|
||||
WEB LEVERAGE: ${composition.webLeverage}`;
|
||||
}
|
||||
|
||||
// One approved staging from the composition catalog, rolled deterministically.
|
||||
// Returns null while the composition pool has no approved entry for the
|
||||
// requested mode. Cross-mode fallback would turn an absent staging into a
|
||||
// misleading one. A re-roll excludes earlier stagings until the pool runs out.
|
||||
export function selectApprovedStaging({ scope, key, reroll = 0, mode = null, sourceCompositions = compositions }) {
|
||||
let approved = sourceCompositions.filter(composition => composition.status === 'approved');
|
||||
if (approved.length === 0) return null;
|
||||
if (mode) {
|
||||
const matching = approved.filter(composition => composition.surface === mode);
|
||||
if (matching.length === 0) return null;
|
||||
approved = matching;
|
||||
}
|
||||
const prior = new Set();
|
||||
let pick = deterministicRank(approved, `${scope}:${key}:staging`)[0];
|
||||
for (let round = 1; round <= reroll; round += 1) {
|
||||
prior.add(pick.id);
|
||||
const pool = approved.filter(composition => !prior.has(composition.id));
|
||||
pick = deterministicRank(
|
||||
pool.length > 0 ? pool : approved,
|
||||
`${scope}:${key}:staging:reroll-${round}`
|
||||
)[0];
|
||||
}
|
||||
return pick;
|
||||
}
|
||||
|
||||
export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = concepts }) {
|
||||
const approved = sourceConcepts.filter(concept => concept.status === 'approved');
|
||||
const approvedByFamily = new Map();
|
||||
// Direction chooses a durable identity, so it draws worlds; surface designs
|
||||
// one page inside a committed identity, so it draws stagings. Duals serve
|
||||
// both. A tier with no matching-strength approvals falls back to its full
|
||||
// approved pool rather than starving the roll.
|
||||
const wanted = scope === 'direction'
|
||||
? new Set(['world', 'dual'])
|
||||
: new Set(['composition', 'dual']);
|
||||
const approvedByTier = new Map();
|
||||
for (const concept of approved) {
|
||||
const family = approvedByFamily.get(concept.familyId) || [];
|
||||
family.push(concept);
|
||||
approvedByFamily.set(concept.familyId, family);
|
||||
const tier = approvedByTier.get(concept.wellTier) || [];
|
||||
tier.push(concept);
|
||||
approvedByTier.set(concept.wellTier, tier);
|
||||
}
|
||||
if (approvedByFamily.size < 3) {
|
||||
throw new Error('concept-seed: at least three families need approved concepts');
|
||||
if (WELL_TIERS.some(tier => !(approvedByTier.get(tier) || []).length)) {
|
||||
throw new Error('concept-seed: every challenger tier needs at least one approved concept');
|
||||
}
|
||||
for (const [tier, pool] of approvedByTier) {
|
||||
const matching = pool.filter(concept => wanted.has(concept.strength));
|
||||
if (matching.length > 0) approvedByTier.set(tier, matching);
|
||||
}
|
||||
// Two challengers per tier, so every roll carries near-zero-translation
|
||||
// graphic systems beside instrument languages and atmosphere worlds, with
|
||||
// the second pick preferring a different family for diversity. Tier order
|
||||
// in the rendered list is rolled too, to avoid positional bias.
|
||||
// Approval ratings weight the draw: a 3-star world earns a second ticket
|
||||
// (roughly double odds), a 1-star keeps its approval for direct briefs but
|
||||
// leaves the challenger pool unless a tier has nothing else.
|
||||
const ticketsFor = pool => pool.flatMap(concept => {
|
||||
const rating = concept.review?.rating;
|
||||
if (rating === 1) return [];
|
||||
return rating === 3
|
||||
? [{ concept, ticket: 0 }, { concept, ticket: 1 }]
|
||||
: [{ concept, ticket: 0 }];
|
||||
});
|
||||
const pickRound = (round, excluded) => {
|
||||
const salt = round === 0 ? '' : `:reroll-${round}`;
|
||||
const tierOrder = deterministicRank(
|
||||
WELL_TIERS.map(id => ({ id })),
|
||||
`${scope}:${key}:tiers${salt}`
|
||||
).map(item => item.id);
|
||||
return tierOrder.flatMap((tier, index) => {
|
||||
let pool = approvedByTier.get(tier).filter(concept => !excluded.has(concept.id));
|
||||
// A tier exhausted by prior rounds falls back to reuse over starvation.
|
||||
if (pool.length === 0) pool = approvedByTier.get(tier);
|
||||
let tickets = ticketsFor(pool);
|
||||
if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 }));
|
||||
const ranked = deterministicRank(
|
||||
tickets,
|
||||
`${scope}:${key}:challenger-${index}${salt}`,
|
||||
entry => `${entry.concept.id}#${entry.ticket}`
|
||||
);
|
||||
const order = [];
|
||||
const seen = new Set();
|
||||
for (const entry of ranked) {
|
||||
if (seen.has(entry.concept.id)) continue;
|
||||
seen.add(entry.concept.id);
|
||||
order.push(entry.concept);
|
||||
}
|
||||
const first = order[0];
|
||||
const second = order.find(concept => concept.familyId !== first.familyId)
|
||||
|| order.find(concept => concept.id !== first.id);
|
||||
return second ? [first, second] : [first];
|
||||
});
|
||||
};
|
||||
// Round n of a re-roll chain excludes everything rounds 0..n-1 drew, so the
|
||||
// same base key reproduces the whole chain.
|
||||
const excluded = new Set();
|
||||
let picks = pickRound(0, excluded);
|
||||
for (let round = 1; round <= reroll; round += 1) {
|
||||
for (const pick of picks) excluded.add(pick.id);
|
||||
picks = pickRound(round, excluded);
|
||||
}
|
||||
const familyIds = deterministicRank(
|
||||
[...approvedByFamily.keys()].map(id => ({ id })),
|
||||
`${scope}:${key}:families`
|
||||
).slice(0, 3).map(item => item.id);
|
||||
const picks = familyIds.map((familyId, index) => deterministicRank(
|
||||
approvedByFamily.get(familyId),
|
||||
`${scope}:${key}:challenger-${index}`
|
||||
)[0]);
|
||||
return {
|
||||
approved,
|
||||
picks,
|
||||
@@ -71,19 +196,30 @@ export function selectApprovedChallengers({ scope, key, sourceConcepts = concept
|
||||
};
|
||||
}
|
||||
|
||||
const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']);
|
||||
|
||||
export function renderConceptSeed({
|
||||
scope = 'surface',
|
||||
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
|
||||
reroll = 0,
|
||||
mode = null,
|
||||
} = {}) {
|
||||
if (scope !== 'surface' && scope !== 'direction') {
|
||||
throw new Error('concept-seed: --scope must be direction or surface');
|
||||
}
|
||||
if (!Number.isInteger(reroll) || reroll < 0) {
|
||||
throw new Error('concept-seed: --reroll must be a non-negative integer');
|
||||
}
|
||||
if (mode !== null && !SEED_MODES.has(mode)) {
|
||||
throw new Error('concept-seed: --mode must be persuade, operate, read, or experience');
|
||||
}
|
||||
const unit = (salt) => {
|
||||
const h = crypto.createHash('sha256').update(`${scope}:${salt}:${key}`).digest();
|
||||
return h.readUInt32BE(0) / 0xffffffff;
|
||||
};
|
||||
const buildIndex = 3 + Math.floor(unit('index') * 5); // 3..7
|
||||
const { approved, picks, poolRevision } = selectApprovedChallengers({ scope, key });
|
||||
const indexSalt = reroll === 0 ? 'index' : `index:reroll-${reroll}`;
|
||||
const buildIndex = 3 + Math.floor(unit(indexSalt) * 5); // 3..7
|
||||
const { approved, picks, poolRevision } = selectApprovedChallengers({ scope, key, reroll });
|
||||
|
||||
const promotedInstruction = scope === 'direction'
|
||||
? `After ordering the grounded coupled directions by product fit, promote
|
||||
@@ -96,15 +232,16 @@ export function renderConceptSeed({
|
||||
promote candidate ${buildIndex} into the serious shortlist. In an attended
|
||||
run, present it beside the strongest materially different candidates and
|
||||
let the user select or revise the surface concept. In a truly unattended
|
||||
run, use it when it survives audience identification and product clarity.`;
|
||||
run, use it when it survives audience identification, product clarity,
|
||||
system leverage, and use of the medium.`;
|
||||
|
||||
const challengerInstruction = scope === 'direction'
|
||||
? `Translate each challenger's organizing logic into reusable identity grammar
|
||||
and a strong first-surface structure before judging it. Noticeable form is
|
||||
allowed when the product stays clear. Compare only audience identification
|
||||
and product clarity.`
|
||||
: `A challenger wins only when it beats the grounded list on both audience
|
||||
identification and product clarity. It may change task topology or
|
||||
allowed when the product stays clear. Compare audience identification,
|
||||
product clarity, system leverage, and use of the medium.`
|
||||
: `A challenger wins only when it beats the grounded list on audience
|
||||
identification, product clarity, system leverage, and use of the medium. It may change task topology or
|
||||
interaction, but never the committed visual identity.`;
|
||||
|
||||
const authorityInstruction = scope === 'direction'
|
||||
@@ -117,17 +254,48 @@ vocabulary; they do not cancel task-level composition. The seed never
|
||||
authorizes a new palette, type system, material world, or unfamiliar control
|
||||
behavior.`;
|
||||
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; approved pool: ${poolRevision}; ${approved.length}/${concepts.length} human-approved; rerun with --scope ${scope} --from ${key} to reproduce this roll against this catalog revision)
|
||||
PROMOTED INDEX: ${buildIndex}
|
||||
const richnessInstruction = `The CREATIVE SPARK is a visual world, artifact, or graphic tradition people
|
||||
would genuinely choose to enter, study, or explore, and whose palette,
|
||||
materials, type voice, and component grammar a designer could sketch on
|
||||
sight, not decorative art direction. The challengers are drawn two from each
|
||||
translation tier: graphic systems that map to interface almost directly,
|
||||
instrument or display languages that carry interaction physics, and material
|
||||
or performed worlds that need the largest translation step; judge
|
||||
each in its own register and pay that translation cost honestly. Translate
|
||||
its scale, material, spatial or compositional law, tension, rhythm, and
|
||||
memorable human experience into product structure. Inherit a movement's or
|
||||
artifact's rules, never just its name: grid, geometry, ornament logic,
|
||||
material behavior, and information structure become the interface's. Preserve
|
||||
the spark's imaginative distance: do not collapse a galaxy into a mission
|
||||
dashboard, a forest into a taxonomy app, or a performance into a control
|
||||
console. Use Three.js, generative motion, film language, typography, craft,
|
||||
or another ambitious medium when it materially strengthens the task; keep
|
||||
semantic structure and graceful fallbacks fully capable.`;
|
||||
|
||||
const staging = selectApprovedStaging({ scope, key, reroll, mode });
|
||||
const stagingBlock = staging
|
||||
? `\n${scope === 'direction' ? 'FIRST-SURFACE STAGING (identity-free; pair it with the chosen world and judge the pair as one decision):' : 'STAGING CHALLENGER (identity-free; dress it in the committed visual identity before judging):'}
|
||||
${renderStaging(staging)}
|
||||
A staging organizes attention, sequence, and manipulation; it never brings a
|
||||
palette, typeface, or material. It competes on structure alone and loses to a
|
||||
grounded structure that fits the product better.\n`
|
||||
: '';
|
||||
const rerollBlock = reroll > 0
|
||||
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
|
||||
and challenger alike, is eliminated and may not return reworded. Derive
|
||||
genuinely new grounded candidates from unexplored angles before judging
|
||||
these fresh challengers.\n`
|
||||
: '';
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; approved pool: ${poolRevision}; ${approved.length}/${concepts.length} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} to reproduce this roll against this catalog revision)
|
||||
${rerollBlock}PROMOTED INDEX: ${buildIndex}
|
||||
${promotedInstruction}
|
||||
The promotion exists to refuse the model's ranking rut, not to outrank the
|
||||
user or the brief. Never expose promotion metadata in choice labels or order.
|
||||
CHALLENGERS:
|
||||
1. ${picks[0].form}
|
||||
2. ${picks[1].form}
|
||||
3. ${picks[2].form}
|
||||
${challengerInstruction}
|
||||
${picks.map(renderChallenger).join('\n')}
|
||||
${stagingBlock}${challengerInstruction}
|
||||
${authorityInstruction}
|
||||
${richnessInstruction}
|
||||
A user- or brief-pinned decision beats the roll, always.
|
||||
`;
|
||||
}
|
||||
@@ -136,12 +304,16 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
const args = process.argv.slice(2);
|
||||
const fromIdx = args.indexOf('--from');
|
||||
const scopeIdx = args.indexOf('--scope');
|
||||
const rerollIdx = args.indexOf('--reroll');
|
||||
const modeIdx = args.indexOf('--mode');
|
||||
try {
|
||||
process.stdout.write(renderConceptSeed({
|
||||
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : 'surface',
|
||||
key: fromIdx !== -1
|
||||
? args[fromIdx + 1]
|
||||
: (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex')),
|
||||
reroll: rerollIdx !== -1 ? Number(args[rerollIdx + 1]) : 0,
|
||||
mode: modeIdx !== -1 ? args[modeIdx + 1] : null,
|
||||
}));
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
|
||||
@@ -141,6 +141,15 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
||||
|
||||
// ---- CLI ---------------------------------------------------------------
|
||||
|
||||
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
|
||||
// callers never have to run the slug step separately. Anything containing a
|
||||
// path or URL marker is resolved through slugFromTarget.
|
||||
function coerceSlug(value) {
|
||||
if (!value) return null;
|
||||
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
|
||||
return slugFromTarget(value);
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const [cmd, ...args] = argv;
|
||||
switch (cmd) {
|
||||
@@ -151,8 +160,9 @@ function main(argv) {
|
||||
return;
|
||||
}
|
||||
case 'write': {
|
||||
const [slug, bodyFile] = args;
|
||||
if (!slug || !bodyFile) { process.stderr.write('usage: write <slug> <body-file>\n'); process.exit(1); }
|
||||
const [slugArg, bodyFile] = args;
|
||||
const slug = coerceSlug(slugArg);
|
||||
if (!slug || !bodyFile) { process.stderr.write('usage: write <slug-or-target> <body-file>\n'); process.exit(1); }
|
||||
const raw = fs.readFileSync(bodyFile, 'utf-8');
|
||||
// The body file may be a full report. The caller passes the meta as
|
||||
// a JSON object on stdin if it wants structured frontmatter; otherwise
|
||||
@@ -167,13 +177,13 @@ function main(argv) {
|
||||
return;
|
||||
}
|
||||
case 'latest': {
|
||||
const latest = readLatestSnapshot(args[0]);
|
||||
const latest = readLatestSnapshot(coerceSlug(args[0]));
|
||||
if (!latest) { process.exit(2); }
|
||||
process.stdout.write(latest.body);
|
||||
return;
|
||||
}
|
||||
case 'trend': {
|
||||
const rows = readTrend(args[0], { limit: args[1] ? Number(args[1]) : 5 });
|
||||
const rows = readTrend(coerceSlug(args[0]), { limit: args[1] ? Number(args[1]) : 5 });
|
||||
process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1846,8 +1846,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
|
||||
// ── Direction-contract audit ─────────────────────────────────────────────
|
||||
// The skill's decide-then-build step opens the built HTML artifact with a
|
||||
// DIRECTION CONTRACT comment (UNIQUE / NOT-TEMPLATE / OWN-WORLD / STORY /
|
||||
// FIRST VIEWPORT / FORM blocks). At Stop time the deep pass extracts that
|
||||
// DIRECTION CONTRACT comment (THESIS / OWN-WORLD / STORY / FIRST VIEWPORT /
|
||||
// BAR-RAISER / FORM blocks). At Stop time the deep pass extracts that
|
||||
// comment and feeds it back so the model audits the render against its own
|
||||
// promises. Proven in the eval harness: sample contracts promised radical
|
||||
// compositions and the build shipped the standard template anyway, because
|
||||
@@ -1870,7 +1870,7 @@ export const CONTRACT_MAX_CHARS = 1800;
|
||||
// stack an unbounded message.
|
||||
export const CONTRACT_AUDIT_MAX_FILES = 3;
|
||||
export const CONTRACT_EXTS = new Set(['.html', '.htm', '.astro', '.svelte', '.vue', '.jsx', '.tsx']);
|
||||
export const CONTRACT_REQUIRED_FIELDS = ['UNIQUE', 'NOT-TEMPLATE', 'OWN-WORLD', 'STORY', 'FIRST VIEWPORT', 'FORM'];
|
||||
export const CONTRACT_REQUIRED_FIELDS = ['THESIS', 'OWN-WORLD', 'STORY', 'FIRST VIEWPORT', 'BAR-RAISER', 'FORM'];
|
||||
|
||||
/**
|
||||
* Extract the artifact's own direction-contract comment from the head of an
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
|
||||
|
||||
// Catalog B: stagings rather than styles. A composition organizes attention,
|
||||
// sequence, or manipulation on a surface and must survive being dressed in
|
||||
// any committed visual identity; it deliberately carries no palette or type
|
||||
// half. Surface-scope seeds draw from here (plus catalog A duals); direction
|
||||
// seeds pair one composition with a chosen world for the first surface.
|
||||
|
||||
export const COMPOSITION_GRAMMAR_PREFIXES = [
|
||||
'Staging/hierarchy:',
|
||||
'Sequence/attention:',
|
||||
'Controls/state:',
|
||||
'Adaptation:',
|
||||
];
|
||||
|
||||
// Surfaces align with the skill's modes: a persuade staging and an operate
|
||||
// staging are different species, and read/experience surfaces get their own.
|
||||
export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']);
|
||||
|
||||
export function compositionContentHash(composition) {
|
||||
const payload = [
|
||||
composition?.form ?? '',
|
||||
composition?.lineage ?? '',
|
||||
JSON.stringify(composition?.tags ?? []),
|
||||
JSON.stringify(composition?.grammar ?? []),
|
||||
composition?.spark ?? '',
|
||||
composition?.webLeverage ?? '',
|
||||
].join('\n');
|
||||
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
export function validateCompositionEntry(composition, { existingForms = new Map() } = {}) {
|
||||
const errors = [];
|
||||
const id = composition?.id || '(unknown)';
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(composition?.id || '')) {
|
||||
errors.push(`invalid composition id: ${String(composition?.id)}`);
|
||||
}
|
||||
const normalized = normalizeConceptForm(composition?.form);
|
||||
if (!normalized) {
|
||||
errors.push(`composition ${id} needs a form`);
|
||||
} else if (existingForms.has(normalized)) {
|
||||
errors.push(`duplicate composition form: ${id} and ${existingForms.get(normalized)}`);
|
||||
}
|
||||
if (typeof composition?.form !== 'string'
|
||||
|| composition.form.trim().length < 40
|
||||
|| composition.form.trim().length > 360
|
||||
|| !composition.form.includes(',')) {
|
||||
errors.push(`composition ${id} must name a staging and its structural mechanism after a comma`);
|
||||
}
|
||||
if (typeof composition?.lineage !== 'string'
|
||||
|| composition.lineage.trim().length < 12
|
||||
|| composition.lineage.trim().length > 200) {
|
||||
errors.push(`composition ${id} needs lineage metadata of 12–200 characters`);
|
||||
}
|
||||
if (!COMPOSITION_SURFACES.has(composition?.surface)) {
|
||||
errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`);
|
||||
}
|
||||
if (!Array.isArray(composition?.tags)
|
||||
|| composition.tags.length !== 3
|
||||
|| composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
||||
errors.push(`composition ${id} must have exactly three structural tags`);
|
||||
}
|
||||
if (!Array.isArray(composition?.grammar)
|
||||
|| composition.grammar.length !== COMPOSITION_GRAMMAR_PREFIXES.length
|
||||
|| composition.grammar.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
|
||||
errors.push(`composition ${id} needs grammar with exactly four rules of 12–180 characters`);
|
||||
} else {
|
||||
const unique = new Set(composition.grammar.map(normalizeConceptForm));
|
||||
if (unique.size !== COMPOSITION_GRAMMAR_PREFIXES.length) {
|
||||
errors.push(`composition ${id} has duplicate grammar rules`);
|
||||
}
|
||||
if (composition.grammar.some((rule, index) => !rule.startsWith(COMPOSITION_GRAMMAR_PREFIXES[index]))) {
|
||||
errors.push(`composition ${id} grammar must use staging, sequence, controls, and adaptation prefixes in order`);
|
||||
}
|
||||
}
|
||||
if (typeof composition?.spark !== 'string'
|
||||
|| composition.spark.trim().length < 80
|
||||
|| composition.spark.trim().length > 320) {
|
||||
errors.push(`composition ${id} needs a vivid spark of 80–320 characters`);
|
||||
}
|
||||
if (typeof composition?.webLeverage !== 'string'
|
||||
|| composition.webLeverage.trim().length < 20
|
||||
|| composition.webLeverage.trim().length > 240) {
|
||||
errors.push(`composition ${id} needs web leverage of 20–240 characters`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function readCompositionCatalog(catalogPath, reviewsPath) {
|
||||
const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'));
|
||||
const reviewData = JSON.parse(readFileSync(reviewsPath, 'utf8'));
|
||||
const reviews = reviewData.reviews || {};
|
||||
const familiesById = new Map((catalog.families || []).map(family => [family.id, family]));
|
||||
const compositions = (catalog.compositions || []).map(composition => ({
|
||||
...composition,
|
||||
familyLabel: familiesById.get(composition.familyId)?.label || null,
|
||||
status: reviews[composition.id]?.status || 'pending',
|
||||
review: reviews[composition.id] || null,
|
||||
}));
|
||||
return { catalog, reviewData, reviews, compositions };
|
||||
}
|
||||
|
||||
export function validateCompositionCatalog(catalog, reviewData, { minimumTotal } = {}) {
|
||||
const errors = [];
|
||||
const familyIds = new Set();
|
||||
const ids = new Set();
|
||||
const forms = new Map();
|
||||
|
||||
if (!Number.isInteger(catalog?.schemaVersion) || catalog.schemaVersion < 1) {
|
||||
errors.push('composition catalog schemaVersion must be a positive integer');
|
||||
}
|
||||
if (typeof catalog?.qualityBar?.principle !== 'string' || catalog.qualityBar.principle.trim().length < 80) {
|
||||
errors.push('composition qualityBar.principle must define the staging bar');
|
||||
}
|
||||
if (!Array.isArray(catalog?.families) || catalog.families.length < 4) {
|
||||
errors.push('composition catalog needs at least four families');
|
||||
}
|
||||
for (const family of catalog?.families || []) {
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(family.id || '')) errors.push(`invalid composition family id: ${String(family.id)}`);
|
||||
if (familyIds.has(family.id)) errors.push(`duplicate composition family id: ${family.id}`);
|
||||
familyIds.add(family.id);
|
||||
if (typeof family.description !== 'string' || family.description.trim().length < 40) {
|
||||
errors.push(`composition family ${family.id || '(unknown)'} needs a description`);
|
||||
}
|
||||
}
|
||||
for (const composition of catalog?.compositions || []) {
|
||||
if (ids.has(composition.id)) errors.push(`duplicate composition id: ${composition.id}`);
|
||||
ids.add(composition.id);
|
||||
if (!familyIds.has(composition.familyId)) {
|
||||
errors.push(`composition ${composition.id} must belong to a declared family, got: ${String(composition.familyId)}`);
|
||||
}
|
||||
errors.push(...validateCompositionEntry(composition, { existingForms: forms }));
|
||||
const normalized = normalizeConceptForm(composition.form);
|
||||
if (normalized) forms.set(normalized, composition.id);
|
||||
}
|
||||
if (minimumTotal !== undefined && (catalog?.compositions || []).length < minimumTotal) {
|
||||
errors.push(`expected at least ${minimumTotal} compositions, found ${(catalog?.compositions || []).length}`);
|
||||
}
|
||||
for (const [id, review] of Object.entries(reviewData?.reviews || {})) {
|
||||
if (!ids.has(id)) errors.push(`composition review references missing entry: ${id}`);
|
||||
if (!CONCEPT_STATUSES.has(review?.status)) errors.push(`invalid composition review status for ${id}`);
|
||||
if (typeof review?.formHash !== 'string' || !review.formHash.trim()) {
|
||||
errors.push(`composition review ${id} needs a formHash`);
|
||||
} else {
|
||||
const entry = (catalog?.compositions || []).find(composition => composition.id === id);
|
||||
if (entry && review.formHash !== compositionContentHash(entry)) {
|
||||
errors.push(`composition review ${id} is stale: content changed since review`);
|
||||
}
|
||||
}
|
||||
if (review?.note !== undefined && (typeof review.note !== 'string' || !review.note.trim() || review.note.length > 500)) {
|
||||
errors.push(`composition review ${id} note must be a non-empty string of 500 characters or fewer`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
errors,
|
||||
stats: {
|
||||
families: familyIds.size,
|
||||
compositions: (catalog?.compositions || []).length,
|
||||
approved: Object.values(reviewData?.reviews || {}).filter(review => review?.status === 'approved').length,
|
||||
rejected: Object.values(reviewData?.reviews || {}).filter(review => review?.status === 'rejected').length,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,30 @@ import { readFileSync } from 'node:fs';
|
||||
|
||||
export const CONCEPT_STATUSES = new Set(['approved', 'rejected']);
|
||||
|
||||
// What a concept is actually strong at. Worlds carry a durable visual
|
||||
// identity (their palette/type half is the magnet); compositions carry a
|
||||
// staging or interaction idea (their topology half is the magnet) that can be
|
||||
// dressed in any committed identity; duals fuse both inseparably. Direction
|
||||
// seeds draw world|dual, surface seeds draw composition|dual.
|
||||
export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']);
|
||||
|
||||
// Challenger tiers, ordered by translation cost: graphic grammars map to
|
||||
// interface almost directly, instrument languages carry interaction physics,
|
||||
// atmosphere worlds need the largest translation step. Every seed roll draws
|
||||
// one challenger from each tier so at least one directly-usable graphic
|
||||
// system is always on the table.
|
||||
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
|
||||
|
||||
const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i;
|
||||
export const SYSTEM_PREFIXES = [
|
||||
'Palette/material:',
|
||||
'Type/composition:',
|
||||
'Topology/navigation:',
|
||||
'Controls/state:',
|
||||
'Responsive/motion:',
|
||||
];
|
||||
const BLAND_FORM_RE = /\b(?:control room|command center|operations center|dispatch desk|review queue|speaker queue|management console|admin console|operator loop|coordination system|tracking system|planning system|software platform|digital platform|operations cockpit|app portal|web portal|data hub|dashboard|workflow|planner|tracker|orchestrator)\b/i;
|
||||
|
||||
export function normalizeConceptForm(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKD')
|
||||
@@ -12,10 +36,93 @@ export function normalizeConceptForm(value) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function validateConceptEntry(concept, { existingForms = new Map() } = {}) {
|
||||
const errors = [];
|
||||
const id = concept?.id || '(unknown)';
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
|
||||
errors.push(`invalid concept id: ${String(concept?.id)}`);
|
||||
}
|
||||
|
||||
const normalized = normalizeConceptForm(concept?.form);
|
||||
if (!normalized) {
|
||||
errors.push(`concept ${id} needs a form`);
|
||||
} else if (existingForms.has(normalized)) {
|
||||
errors.push(`duplicate concept form: ${id} and ${existingForms.get(normalized)}`);
|
||||
}
|
||||
if (typeof concept?.form !== 'string'
|
||||
|| concept.form.trim().length < 40
|
||||
|| concept.form.trim().length > 360
|
||||
|| !concept.form.includes(',')) {
|
||||
errors.push(`concept ${id} must name a form and inherited structure after a comma`);
|
||||
}
|
||||
if (typeof concept?.lineage !== 'string'
|
||||
|| concept.lineage.trim().length < 12
|
||||
|| concept.lineage.trim().length > 200) {
|
||||
errors.push(`concept ${id} needs specific lineage metadata of 12–200 characters`);
|
||||
}
|
||||
if (!CONCEPT_STRENGTHS.has(concept?.strength)) {
|
||||
errors.push(`concept ${id} needs a strength of ${[...CONCEPT_STRENGTHS].join(', ')}`);
|
||||
}
|
||||
if (!Array.isArray(concept?.tags)
|
||||
|| concept.tags.length !== 3
|
||||
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
||||
errors.push(`concept ${id} must have exactly three structural tags`);
|
||||
}
|
||||
if (!Array.isArray(concept?.system)
|
||||
|| concept.system.length !== SYSTEM_PREFIXES.length
|
||||
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
|
||||
errors.push(`concept ${id} needs system grammar with exactly five rules of 12–180 characters`);
|
||||
} else {
|
||||
const uniqueRules = new Set(concept.system.map(normalizeConceptForm));
|
||||
if (uniqueRules.size !== SYSTEM_PREFIXES.length) {
|
||||
errors.push(`concept ${id} has duplicate system grammar rules`);
|
||||
}
|
||||
if (concept.system.some((rule, index) => !rule.startsWith(SYSTEM_PREFIXES[index]))) {
|
||||
errors.push(`concept ${id} system grammar must use palette, type, topology, controls, and responsive prefixes in order`);
|
||||
}
|
||||
}
|
||||
if (typeof concept?.spark !== 'string'
|
||||
|| concept.spark.trim().length < 80
|
||||
|| concept.spark.trim().length > 320) {
|
||||
errors.push(`concept ${id} needs a vivid creative spark of 80–320 characters`);
|
||||
}
|
||||
if (typeof concept?.webLeverage !== 'string'
|
||||
|| concept.webLeverage.trim().length < 20
|
||||
|| concept.webLeverage.trim().length > 240) {
|
||||
errors.push(`concept ${id} needs web leverage of 20–240 characters`);
|
||||
}
|
||||
if (/\b(?:live digital system|shared participatory system) modeled on\b/i.test(concept?.form || '')) {
|
||||
errors.push(`concept ${id} is a generic wrapper around another artifact`);
|
||||
}
|
||||
if (/\b(?:in the style of|styled like|copy of)\b/i.test(concept?.form || '')) {
|
||||
errors.push(`concept ${id} contains imitation language`);
|
||||
}
|
||||
if (BLAND_FORM_RE.test(concept?.form || '')) {
|
||||
errors.push(`concept ${id} is framed as a literal software or operations archetype instead of an inspiring visual world`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Fingerprint of everything a reviewer judged. Reviews carry this hash so an
|
||||
// approval cannot silently survive a content edit: the validator rejects any
|
||||
// review whose hash no longer matches the concept it points at.
|
||||
export function conceptContentHash(concept) {
|
||||
const payload = [
|
||||
concept?.form ?? '',
|
||||
concept?.lineage ?? '',
|
||||
JSON.stringify(concept?.tags ?? []),
|
||||
JSON.stringify(concept?.system ?? []),
|
||||
concept?.spark ?? '',
|
||||
concept?.webLeverage ?? '',
|
||||
].join('\n');
|
||||
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
export function readConceptCatalog(catalogPath, reviewsPath) {
|
||||
const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'));
|
||||
const reviewData = JSON.parse(readFileSync(reviewsPath, 'utf8'));
|
||||
const reviews = reviewData.reviews || {};
|
||||
const wellsById = new Map((catalog.wells || []).map(well => [well.id, well]));
|
||||
const concepts = [];
|
||||
|
||||
for (const family of catalog.families || []) {
|
||||
@@ -24,6 +131,9 @@ export function readConceptCatalog(catalogPath, reviewsPath) {
|
||||
...concept,
|
||||
familyId: family.id,
|
||||
familyLabel: family.label,
|
||||
wellId: family.well || null,
|
||||
wellLabel: wellsById.get(family.well)?.label || null,
|
||||
wellTier: wellsById.get(family.well)?.tier || null,
|
||||
status: reviews[concept.id]?.status || 'pending',
|
||||
review: reviews[concept.id] || null,
|
||||
});
|
||||
@@ -33,7 +143,11 @@ export function readConceptCatalog(catalogPath, reviewsPath) {
|
||||
return { catalog, reviewData, reviews, concepts };
|
||||
}
|
||||
|
||||
export function validateConceptCatalog(catalog, reviewData, { expectedTotal, minimumTotal } = {}) {
|
||||
export function validateConceptCatalog(catalog, reviewData, {
|
||||
expectedTotal,
|
||||
minimumTotal,
|
||||
requireApprovedMinimum = true,
|
||||
} = {}) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const familyIds = new Set();
|
||||
@@ -41,16 +155,54 @@ export function validateConceptCatalog(catalog, reviewData, { expectedTotal, min
|
||||
const normalizedForms = new Map();
|
||||
const concepts = [];
|
||||
|
||||
if (!Number.isInteger(catalog?.schemaVersion) || catalog.schemaVersion < 1) {
|
||||
errors.push('catalog.schemaVersion must be a positive integer');
|
||||
if (!Number.isInteger(catalog?.schemaVersion) || catalog.schemaVersion < 7) {
|
||||
errors.push('catalog.schemaVersion must be 7 or newer');
|
||||
}
|
||||
if (typeof catalog?.catalogVersion !== 'string' || !catalog.catalogVersion.trim()) {
|
||||
errors.push('catalog.catalogVersion must be a non-empty string');
|
||||
}
|
||||
if (typeof catalog?.qualityBar?.principle !== 'string' || catalog.qualityBar.principle.trim().length < 80) {
|
||||
errors.push('catalog.qualityBar.principle must define the universal creative bar');
|
||||
}
|
||||
if (!Array.isArray(catalog?.qualityBar?.rejectIf) || catalog.qualityBar.rejectIf.length < 5) {
|
||||
errors.push('catalog.qualityBar.rejectIf must define at least five rejection gates');
|
||||
}
|
||||
if (!Array.isArray(catalog?.qualityBar?.reviewAxes) || catalog.qualityBar.reviewAxes.length < 8) {
|
||||
errors.push('catalog.qualityBar.reviewAxes must define at least eight review axes');
|
||||
}
|
||||
if (!Array.isArray(catalog?.families) || catalog.families.length < 3) {
|
||||
errors.push('catalog.families must contain at least three families');
|
||||
}
|
||||
|
||||
const wellIds = new Set();
|
||||
if (!Array.isArray(catalog?.wells) || catalog.wells.length < 5) {
|
||||
errors.push('catalog.wells must define at least five inspiration wells');
|
||||
}
|
||||
for (const well of catalog?.wells || []) {
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(well.id || '')) {
|
||||
errors.push(`invalid well id: ${String(well.id)}`);
|
||||
} else if (wellIds.has(well.id)) {
|
||||
errors.push(`duplicate well id: ${well.id}`);
|
||||
}
|
||||
wellIds.add(well.id);
|
||||
if (typeof well.label !== 'string' || !well.label.trim()) {
|
||||
errors.push(`well ${well.id || '(unknown)'} needs a label`);
|
||||
}
|
||||
if (typeof well.description !== 'string' || well.description.trim().length < 40) {
|
||||
errors.push(`well ${well.id || '(unknown)'} needs a description of at least 40 characters`);
|
||||
}
|
||||
if (!WELL_TIERS.includes(well.tier)) {
|
||||
errors.push(`well ${well.id || '(unknown)'} needs a tier of ${WELL_TIERS.join(', ')}, got: ${String(well.tier)}`);
|
||||
}
|
||||
}
|
||||
const tiersPresent = new Set((catalog?.wells || []).map(well => well.tier).filter(tier => WELL_TIERS.includes(tier)));
|
||||
for (const tier of WELL_TIERS) {
|
||||
if ((catalog?.wells || []).length > 0 && !tiersPresent.has(tier)) {
|
||||
errors.push(`no well declares the ${tier} tier`);
|
||||
}
|
||||
}
|
||||
const populatedWells = new Set();
|
||||
|
||||
for (const family of catalog?.families || []) {
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(family.id || '')) {
|
||||
errors.push(`invalid family id: ${String(family.id)}`);
|
||||
@@ -61,6 +213,11 @@ export function validateConceptCatalog(catalog, reviewData, { expectedTotal, min
|
||||
if (typeof family.label !== 'string' || !family.label.trim()) {
|
||||
errors.push(`family ${family.id || '(unknown)'} needs a label`);
|
||||
}
|
||||
if (!wellIds.has(family.well)) {
|
||||
errors.push(`family ${family.id || '(unknown)'} must belong to a declared well, got: ${String(family.well)}`);
|
||||
} else {
|
||||
populatedWells.add(family.well);
|
||||
}
|
||||
if (!Array.isArray(family.concepts) || family.concepts.length === 0) {
|
||||
errors.push(`family ${family.id || '(unknown)'} has no concepts`);
|
||||
continue;
|
||||
@@ -68,33 +225,22 @@ export function validateConceptCatalog(catalog, reviewData, { expectedTotal, min
|
||||
|
||||
for (const concept of family.concepts) {
|
||||
concepts.push(concept);
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept.id || '')) {
|
||||
errors.push(`invalid concept id: ${String(concept.id)}`);
|
||||
} else if (conceptIds.has(concept.id)) {
|
||||
if (conceptIds.has(concept.id)) {
|
||||
errors.push(`duplicate concept id: ${concept.id}`);
|
||||
}
|
||||
errors.push(...validateConceptEntry(concept, { existingForms: normalizedForms }));
|
||||
conceptIds.add(concept.id);
|
||||
|
||||
const normalized = normalizeConceptForm(concept.form);
|
||||
if (!normalized) {
|
||||
errors.push(`concept ${concept.id || '(unknown)'} needs a form`);
|
||||
} else if (normalizedForms.has(normalized)) {
|
||||
errors.push(`duplicate concept form: ${concept.id} and ${normalizedForms.get(normalized)}`);
|
||||
if (normalized) normalizedForms.set(normalized, concept.id);
|
||||
if (typeof concept.webLeverage === 'string' && !WEB_LEVERAGE_RE.test(concept.webLeverage)) {
|
||||
warnings.push(`concept ${concept.id} web leverage should be checked for a specific browser-native capability`);
|
||||
}
|
||||
normalizedForms.set(normalized, concept.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof concept.form !== 'string' || !concept.form.includes(',')) {
|
||||
errors.push(`concept ${concept.id || '(unknown)'} must name a form and inherited structure after a comma`);
|
||||
}
|
||||
if (typeof concept.lineage !== 'string' || !concept.lineage.trim()) {
|
||||
errors.push(`concept ${concept.id || '(unknown)'} needs lineage metadata`);
|
||||
}
|
||||
if (!Array.isArray(concept.tags) || concept.tags.length !== 3 || concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
||||
errors.push(`concept ${concept.id || '(unknown)'} must have exactly three structural tags`);
|
||||
}
|
||||
if (/\b(?:in the style of|styled like|copy of)\b/i.test(concept.form)) {
|
||||
errors.push(`concept ${concept.id || '(unknown)'} contains imitation language`);
|
||||
}
|
||||
for (const well of catalog?.wells || []) {
|
||||
if (well.id && !populatedWells.has(well.id)) {
|
||||
errors.push(`well ${well.id} has no families`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,9 +251,10 @@ export function validateConceptCatalog(catalog, reviewData, { expectedTotal, min
|
||||
errors.push(`expected at least ${minimumTotal} concepts, found ${concepts.length}`);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(reviewData?.schemaVersion) || reviewData.schemaVersion < 1) {
|
||||
errors.push('reviews.schemaVersion must be a positive integer');
|
||||
if (!Number.isInteger(reviewData?.schemaVersion) || reviewData.schemaVersion < 2) {
|
||||
errors.push('reviews.schemaVersion must be 2 or newer');
|
||||
}
|
||||
const conceptsById = new Map(concepts.map(concept => [concept.id, concept]));
|
||||
for (const [id, review] of Object.entries(reviewData?.reviews || {})) {
|
||||
if (!conceptIds.has(id)) errors.push(`review references missing concept: ${id}`);
|
||||
if (!CONCEPT_STATUSES.has(review?.status)) errors.push(`invalid review status for ${id}: ${String(review?.status)}`);
|
||||
@@ -117,21 +264,44 @@ export function validateConceptCatalog(catalog, reviewData, { expectedTotal, min
|
||||
if (typeof review?.reviewedAt !== 'string' || Number.isNaN(Date.parse(review.reviewedAt))) {
|
||||
errors.push(`review ${id} needs an ISO reviewedAt timestamp`);
|
||||
}
|
||||
if (typeof review?.formHash !== 'string' || !review.formHash.trim()) {
|
||||
errors.push(`review ${id} needs a formHash of the reviewed content`);
|
||||
} else if (conceptsById.has(id) && review.formHash !== conceptContentHash(conceptsById.get(id))) {
|
||||
errors.push(`review ${id} is stale: concept content changed since it was reviewed; reset or re-review it`);
|
||||
}
|
||||
if (review?.note !== undefined && (typeof review.note !== 'string' || !review.note.trim() || review.note.length > 500)) {
|
||||
errors.push(`review ${id} note must be a non-empty string of 500 characters or fewer`);
|
||||
}
|
||||
// Rating grades how strong an approved concept is (3 exceptional, 2 solid,
|
||||
// 1 marginal keep). Optional, approved-only, and read as a calibration
|
||||
// signal for future authoring rounds.
|
||||
if (review?.rating !== undefined) {
|
||||
if (![1, 2, 3].includes(review.rating)) {
|
||||
errors.push(`review ${id} rating must be 1, 2, or 3`);
|
||||
} else if (review.status !== 'approved') {
|
||||
errors.push(`review ${id} rating only applies to approved concepts`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier]));
|
||||
const approved = concepts.filter(concept => reviewData?.reviews?.[concept.id]?.status === 'approved');
|
||||
const approvedFamilies = new Set(
|
||||
const approvedTiers = new Set(
|
||||
(catalog?.families || [])
|
||||
.filter(family => family.concepts?.some(concept => reviewData?.reviews?.[concept.id]?.status === 'approved'))
|
||||
.map(family => family.id)
|
||||
.map(family => wellTierById.get(family.well))
|
||||
.filter(tier => WELL_TIERS.includes(tier))
|
||||
);
|
||||
if (approved.length < 3) errors.push('at least three concepts must be approved');
|
||||
if (approvedFamilies.size < 3) errors.push('approved concepts must span at least three families');
|
||||
if (requireApprovedMinimum && approved.length < 3) errors.push('at least three concepts must be approved');
|
||||
if (requireApprovedMinimum && approvedTiers.size < WELL_TIERS.length) {
|
||||
errors.push('approved concepts must cover every challenger tier');
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
warnings,
|
||||
stats: {
|
||||
wells: wellIds.size,
|
||||
families: familyIds.size,
|
||||
concepts: concepts.length,
|
||||
approved: approved.length,
|
||||
@@ -144,7 +314,7 @@ export function validateConceptCatalog(catalog, reviewData, { expectedTotal, min
|
||||
export function approvedPoolRevision(concepts) {
|
||||
const payload = concepts
|
||||
.filter(concept => concept.status === 'approved')
|
||||
.map(concept => `${concept.familyId}:${concept.id}:${concept.form}`)
|
||||
.map(concept => `${concept.familyId}:${concept.id}:${concept.strength}:${concept.form}:${concept.spark}:${JSON.stringify(concept.system)}:${concept.webLeverage}`)
|
||||
.sort()
|
||||
.join('\n');
|
||||
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 12);
|
||||
|
||||
@@ -3,20 +3,38 @@
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readConceptCatalog, validateConceptCatalog } from './lib/concept-catalog.mjs';
|
||||
import { readCompositionCatalog, validateCompositionCatalog } from './lib/composition-catalog.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const { catalog, reviewData } = readConceptCatalog(
|
||||
join(here, 'concept-ingredients.json'),
|
||||
join(here, 'concept-reviews.json')
|
||||
);
|
||||
const result = validateConceptCatalog(catalog, reviewData, { minimumTotal: 2304 });
|
||||
const result = validateConceptCatalog(catalog, reviewData, { minimumTotal: 260 });
|
||||
|
||||
const compositionState = readCompositionCatalog(
|
||||
join(here, 'composition-ingredients.json'),
|
||||
join(here, 'composition-reviews.json')
|
||||
);
|
||||
const compositionResult = validateCompositionCatalog(compositionState.catalog, compositionState.reviewData);
|
||||
|
||||
let failed = false;
|
||||
if (result.errors.length > 0) {
|
||||
for (const error of result.errors) process.stderr.write(`concept-catalog: ${error}\n`);
|
||||
process.exitCode = 1;
|
||||
failed = true;
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`concept-catalog: ${result.stats.concepts} concepts across ${result.stats.families} families; ` +
|
||||
`concept-catalog: ${result.stats.concepts} concepts across ${result.stats.families} families in ${result.stats.wells} wells; ` +
|
||||
`${result.stats.approved} approved, ${result.stats.pending} pending, ${result.stats.rejected} rejected\n`
|
||||
);
|
||||
}
|
||||
if (compositionResult.errors.length > 0) {
|
||||
for (const error of compositionResult.errors) process.stderr.write(`composition-catalog: ${error}\n`);
|
||||
failed = true;
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`composition-catalog: ${compositionResult.stats.compositions} compositions across ${compositionResult.stats.families} families; ` +
|
||||
`${compositionResult.stats.approved} approved, ${compositionResult.stats.rejected} rejected\n`
|
||||
);
|
||||
}
|
||||
if (failed) process.exitCode = 1;
|
||||
|
||||
Reference in New Issue
Block a user