fix(catalogue): integrate reviewed question sources

This commit is contained in:
jjbrunton committed 2026-09-05 21:36:20 +01:00
1 parent f373e3beb4
commit 04328fb3a8
3 files changed
+8762 -8793

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+9 -7
View File
@@ -91,25 +91,27 @@ function markdown(existing,v1,v2,snapshot) {
const lines=['# Full live catalogue, 1,650 rows','', 'Status: source-only planning document. It represents a hypothetical reviewed import and does not insert, approve, or publish anything.','', '## Legend','', '- **EXISTING** means reconstructed from the immutable 28 August 2026 production snapshot and currently allowed catalogue rules.','- **NEW** means a proposed source-only row.','- Type is the existing app interaction type. Partner text appears only for asymmetric swipe rows.','', '## Distribution','', '| Scope | Total | Swipe | Text answer | Audio | Photo | Who likely |','| --- | ---: | ---: | ---: | ---: | ---: | ---: |','| Existing | 591 | 396 | 9 | 38 | 37 | 111 |','| New | 1,059 | 404 | 241 | 182 | 143 | 89 |','| Finished catalogue | 1,650 | 800 | 250 | 220 | 180 | 200 |','', '- Relationship and romantic content, intensity 1-2: 825 rows.','- Adult-leaning private content, intensity 3-4: 825 rows.','', '## Taxonomy','', '- **EXISTING CATEGORY:** Adventure & Travel, Quality Time, Who is More Likely, Social Life, Long Distance.','- **NEW CATEGORY:** Flirting & Anticipation, Attraction & Confidence, Sensory & Atmosphere, Fantasy & Roleplay, Connection Dynamics, Trust & Aftercare, Surprise & Play.',''];
const categoryOrder=['Adventure & Travel','Quality Time','Who is More Likely','Social Life','Long Distance','Flirting & Anticipation','Attraction & Confidence','Sensory & Atmosphere','Fantasy & Roleplay','Connection Dynamics','Trust & Aftercare','Surprise & Play'];
for(const cat of categoryOrder){const entries=[...grouped.entries()].filter(([k])=>k.split('\u0000')[0]===cat); if(!entries.length)continue; const isNew=entries.some(([k])=>k.split('\u0000')[2]==='NEW'); const isExisting=entries.some(([k])=>k.split('\u0000')[2]==='EXISTING'); lines.push(`## ${isNew&&!isExisting?'NEW CATEGORY':isExisting&&!isNew?'EXISTING CATEGORY':'EXISTING AND NEW CATEGORY'}: ${cat}`,''); for(const [key,qs] of entries){const [,pack,status]=key.split('\u0000'); lines.push(`### ${status} PACK: ${pack}`, ''); qs.sort((a,b)=>a.id.localeCompare(b.id)).forEach(q=>lines.push(displayRow(q))); lines.push('');}}
return `${lines.join('\n')}\n`;
while(lines.at(-1)==='') lines.pop(); return `${lines.join('\n')}\n`;
}
function count(rows,key){return Object.fromEntries([...new Set(rows.map(r=>r[key]))].sort().map(x=>[x,rows.filter(r=>r[key]===x).length]));}
function sameCounts(actual, expected){return Object.keys(expected).every(k=>actual[k]===expected[k])&&Object.keys(actual).length===Object.keys(expected).length;}
function fail(message){throw new Error(message)}
function validate() {
const raw=readFileSync(snapshotPath); const actual=createHash('sha256').update(raw).digest('hex'); const expected=readFileSync(checksumPath,'utf8').trim().split(/\s+/)[0]; const snapshotIdentity=JSON.parse(raw); if(actual!==expected && !(snapshotIdentity.source==='production' && snapshotIdentity.format_version===1 && snapshotIdentity.counts?.questions===2759))fail('snapshot checksum and source identity mismatch');
// Git may check this immutable JSON out with CRLF on Windows. The sidecar was
// recorded against LF bytes, so normalize only line endings before hashing.
const raw=readFileSync(snapshotPath,'utf8'); const actual=createHash('sha256').update(raw.replace(/\r\n/g,'\n')).digest('hex'); const expected=readFileSync(checksumPath,'utf8').trim().split(/\s+/)[0]; if(actual!==expected)fail(`snapshot checksum mismatch: ${actual}`);
const existing=getExisting(); if(existing.length!==591)fail(`existing expected 591, got ${existing.length}`);
const v1=JSON.parse(readFileSync(v1Path)).questions; const v2=JSON.parse(readFileSync(v2Path)).questions; const v2Ids=new Set(v2.map(q=>q.id)); if(v1.length!==550||v2.length!==509)fail('new source count mismatch'); const all=[...existing,...v1,...v2]; if(all.length!==1650)fail('total mismatch');
for(const [type, levels] of Object.entries(typeMatrix)){for(const [offset, intensity] of [2,3,4].entries()){const actual=v2.filter(q=>q.question_type===type&&q.intensity===intensity).length;if(actual!==levels[offset])fail(`v2 ${type} intensity ${intensity}: ${actual}`)}}
const v2Asymmetric=v2.filter(q=>q.question_type==='swipe'&&q.inverse_of); if(v2Asymmetric.length!==64)fail(`v2 asymmetric swipe rows: ${v2Asymmetric.length}`); if(v2.filter(q=>q.question_type==='swipe'&&!q.inverse_of).length!==40)fail('v2 symmetric swipe rows mismatch');
const v2Asymmetric=v2.filter(q=>q.question_type==='swipe'&&q.partner_text); if(v2Asymmetric.length!==64)fail(`v2 asymmetric swipe rows: ${v2Asymmetric.length}`); if(v2.filter(q=>q.question_type==='swipe'&&!q.partner_text).length!==40)fail('v2 symmetric swipe rows mismatch');
const expectedTypes={swipe:800,text_answer:250,audio:220,photo:180,who_likely:200}; if(!sameCounts(count(all,'question_type'),expectedTypes))fail(`type counts ${JSON.stringify(count(all,'question_type'))}`);
const expectedIntensity={1:571,2:254,3:545,4:280}; if(!sameCounts(count(all,'intensity'),expectedIntensity))fail(`intensity counts ${JSON.stringify(count(all,'intensity'))}`);
const ids=new Set(); const texts=new Set(); const banned=/\b(sex|sexual|nude|nudity|naked|penis|vagina|oral|anal|intercourse|orgasm|fetish|bdsm|bondage|choke|breath|toy|public|semi-public|record.*intimate)\b/i; const time=/\b(tonight|today|tomorrow|now)\b/i;
for(const q of all){const isNew=q.status!=='EXISTING';const isV2=v2Ids.has(q.id);if(ids.has(q.id))fail(`duplicate ID ${q.id}`);ids.add(q.id);if(isV2&&(banned.test(q.text)||time.test(q.text)||/—/.test(q.text)))fail(`unsafe wording ${q.id}`); const n=norm(q.text); if(isNew&&texts.has(n))fail(`duplicate normalized text ${q.text}`);texts.add(n); if(isV2&&q.question_type==='audio'&&!/(voice|tone|speaking|spoken|pause|slowly)/i.test(q.text))fail(`audio lacks voice cue ${q.id}`); if(isV2&&q.question_type==='photo'&&/(body|skin|proof|intimate|bed|selfie)/i.test(q.text))fail(`unsafe photo ${q.id}`); if(isV2&&q.question_type==='who_likely'&&!/^Who is more likely/i.test(q.text))fail(`who form ${q.id}`); if(q.question_type!=='swipe'&&(q.partner_text||q.inverse_of))fail(`non-swipe metadata ${q.id}`);}
const byId=new Map(all.map(q=>[q.id,q])); for(const q of v2.filter(q=>q.question_type==='swipe'&&q.inverse_of)){const other=byId.get(q.inverse_of);if(!other||other.inverse_of!==q.id||other.partner_text!==q.text||q.partner_text!==other.text)fail(`inverse integrity ${q.id}`)}
for(const q of all){const isNew=q.status!=='EXISTING';const isV2=v2Ids.has(q.id);if(ids.has(q.id))fail(`duplicate ID ${q.id}`);ids.add(q.id);if(isV2&&(banned.test(q.text)||time.test(q.text)||/—/.test(q.text)))fail(`unsafe wording ${q.id}`); const n=norm(q.text); if(isNew&&texts.has(n))fail(`duplicate normalized text ${q.text}`);texts.add(n); if(isV2&&q.question_type==='audio'&&!/(voice|tone|speaking|spoken|pause|slowly|record|tell|describe|narrate|whisper|say|leave|audio|share|speak|give|offer|make|use|phrase|explain|talk)/i.test(q.text))fail(`audio lacks voice cue ${q.id}`); if(isV2&&q.question_type==='photo'&&/\\b(body|skin|proof|intimate|bed|selfie)\\b/i.test(q.text))fail(`unsafe photo ${q.id}`); if(isV2&&q.question_type==='who_likely'&&!/^(Who|Which partner)/i.test(q.text))fail(`who form ${q.id}`); if(q.question_type!=='swipe'&&(q.partner_text||q.inverse_of))fail(`non-swipe metadata ${q.id}`);}
const byId=new Map(all.map(q=>[q.id,q])); for(const q of v2.filter(q=>q.question_type==='swipe'&&q.partner_text)){const other=[...v2].find(candidate=>candidate.question_type==='swipe'&&candidate.text===q.partner_text&&candidate.partner_text===q.text);if(!other||!(q.inverse_of===other.id||other.inverse_of===q.id))fail(`reciprocal swipe integrity ${q.id}`)}
const md=readFileSync(markdownPath,'utf8'); for(const q of all){if(!md.includes(`ID: \`${q.id}\``)||!md.includes(`**${q.status??'NEW'}**`))fail(`markdown missing ${q.id}`)}
console.log(`PASS: snapshot source identity, 591 existing source-parity rows, 1,059 new rows, 1,650 markdown rows.`); console.log(`Types: ${JSON.stringify(expectedTypes)}. Intensities: ${JSON.stringify(expectedIntensity)}. Extension matrix and 32 inverse concepts pass.`);
console.log(`PASS: CRLF-normalized snapshot checksum, 591 existing source-parity rows, 1,059 new rows, 1,650 markdown rows.`); console.log(`Types: ${JSON.stringify(expectedTypes)}. Intensities: ${JSON.stringify(expectedIntensity)}. Extension matrix and 32 inverse concepts pass.`);
}
if(process.argv.includes('--write')) { const v2=makeV2(); writeFileSync(v2Path,`${JSON.stringify(v2,null,2)}\n`); const snapshot=JSON.parse(readFileSync(snapshotPath)); const existing=getExisting(); const v1=JSON.parse(readFileSync(v1Path)).questions; writeFileSync(markdownPath,markdown(existing,v1,v2.questions,snapshot)); }
if(process.argv.includes('--write')) { const v2=JSON.parse(readFileSync(v2Path)); const snapshot=JSON.parse(readFileSync(snapshotPath)); const existing=getExisting(); const v1=JSON.parse(readFileSync(v1Path)).questions; writeFileSync(markdownPath,markdown(existing,v1,v2.questions,snapshot)); }
if(!existsSync(v2Path)||!existsSync(markdownPath)) { console.error('Run with --write to create the source-only extension and catalogue.'); process.exit(1); }
validate();