mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
Two gaps, both reported from real use. Worlds were drawn with no mode awareness at all: selectApprovedChallengers never received the mode, so a build asking for an app UI could draw six worlds that only make sense on a landing page. And surface alone is too coarse for compositions, because "operate" spans onboarding, dashboards, editors and settings, so an onboarding flow could legitimately be dealt a settings composition. Worlds gain `allowedModes` on the review record, beside breadth and rating, because it is a reviewer judgment rather than authored content. Absent means eligible in every mode, so nothing needs backfilling and no existing roll changes. Applied per tier and skipped where it would empty one, matching how minRating and strength already degrade. It is a ceiling the reviewer lowers, not a category they assign: a world is an identity, and identities transfer across modes further than compositions do. Compositions gain an optional `area`, one level below surface, with a taxonomy per surface (COMPOSITION_AREAS). Area is a preference rather than a filter: a request reorders the ranking to put area matches first and tops up from the rest of the surface, because the per-area pools are small and dealing one on-target composition would be worse than three good ones. A stable partition of an already deterministic ranking stays deterministic. `--area` on the CLI requires `--mode`, since areas are scoped to a surface, and is validated against that surface's list so a wrong-surface area fails loudly instead of silently matching nothing. Also validated `breadth`, which selection has honoured for a while with nothing checking it, so a typo read as "general" and quietly returned a narrow world to the pool. Four new tests: worlds excluded from a mode stay out, absent allowedModes stays eligible everywhere, a tier whose every world excludes the mode falls back instead of starving, and an area-scoped deal prefers its area, tops up to three, and reproduces from its key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
208 lines
10 KiB
JavaScript
208 lines
10 KiB
JavaScript
import crypto from 'node:crypto';
|
||
import { readFileSync } from 'node:fs';
|
||
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
|
||
|
||
// Catalog B: compositions 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 composition and an operate
|
||
// composition are different species, and read/experience surfaces get their own.
|
||
export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']);
|
||
|
||
// Areas of concern, one level below surface. Surface alone is too coarse to deal
|
||
// against: "operate" spans onboarding, settings, dashboards and editors, so a
|
||
// build designing an onboarding flow could legitimately draw a settings
|
||
// composition and the input would read as noise. Areas name the problem the
|
||
// composition is about, not how it is built, which is what familyId already does.
|
||
//
|
||
// `area` is optional. An entry without one is eligible for any request in its
|
||
// surface, so nothing has to be backfilled before this ships, and a request for
|
||
// an area with a thin pool tops up from the rest of the surface rather than
|
||
// dealing fewer.
|
||
export const COMPOSITION_AREAS = {
|
||
persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'],
|
||
operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'],
|
||
read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'],
|
||
experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'],
|
||
};
|
||
|
||
export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat());
|
||
|
||
export function areasForSurface(surface) {
|
||
return COMPOSITION_AREAS[surface] ?? [];
|
||
}
|
||
|
||
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(', ')}`);
|
||
}
|
||
// Optional, but an area from the wrong surface is a mistake rather than a
|
||
// looser tag: it would make the entry unreachable by every real request.
|
||
if (composition?.area !== undefined && composition.area !== null) {
|
||
const allowed = areasForSurface(composition.surface);
|
||
if (!allowed.includes(composition.area)) {
|
||
errors.push(
|
||
`composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.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`);
|
||
}
|
||
}
|
||
// Mirrors the concept catalog: an optional 1-3 grade on approved entries
|
||
// only, read as a calibration signal and used to weight challenger draws.
|
||
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 compositions`);
|
||
}
|
||
}
|
||
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,
|
||
},
|
||
};
|
||
}
|