36 lines
4.8 KiB
JavaScript
36 lines
4.8 KiB
JavaScript
import fs from 'node:fs'
|
|
import crypto from 'node:crypto'
|
|
const sourcePath='contracts/experiments/kbx.experiments.json'
|
|
const text=fs.readFileSync(sourcePath,'utf8'), src=JSON.parse(text)
|
|
const sha=crypto.createHash('sha256').update(text).digest('hex')
|
|
const telemetry=JSON.parse(fs.readFileSync('contracts/telemetry/kbx.telemetry.json','utf8'))
|
|
const metricKeys=new Set(telemetry.metrics.map(x=>x.key))
|
|
const flagIds=new Set(), expIds=new Set(), allowed=new Set(src.principles.allowedSurfaces)
|
|
for(const flag of src.featureFlags){
|
|
if(flagIds.has(flag.id))throw new Error(`duplicate feature flag: ${flag.id}`);flagIds.add(flag.id)
|
|
if(!allowed.has(flag.surface))throw new Error(`unsafe/unknown rollout surface: ${flag.id} -> ${flag.surface}`)
|
|
if(flag.killSwitch!==true)throw new Error(`kill switch is required: ${flag.id}`)
|
|
}
|
|
const runningByScreen=new Map()
|
|
for(const exp of src.experiments){
|
|
if(expIds.has(exp.id))throw new Error(`duplicate experiment: ${exp.id}`);expIds.add(exp.id)
|
|
if(!flagIds.has(exp.flagId))throw new Error(`experiment references unknown flag: ${exp.id}`)
|
|
if(exp.rolloutPercent<0||exp.rolloutPercent>100)throw new Error(`invalid rollout percent: ${exp.id}`)
|
|
if(exp.minExposurePerVariant<100)throw new Error(`minimum sample is too small for operational decision: ${exp.id}`)
|
|
if(exp.variants.filter(x=>x.key==='control').length!==1)throw new Error(`exactly one control variant required: ${exp.id}`)
|
|
if(exp.variants.reduce((n,x)=>n+x.weight,0)!==100)throw new Error(`variant weights must total 100: ${exp.id}`)
|
|
for(const metric of [exp.primaryMetric,...(exp.guardrails??[]),...(exp.globalGuardrails??[])])if(!metricKeys.has(metric.key))throw new Error(`unknown UX metric ${metric.key} in ${exp.id}`)
|
|
if(exp.state==='running')runningByScreen.set(exp.screenId,(runningByScreen.get(exp.screenId)??0)+1)
|
|
}
|
|
for(const [screen,count] of runningByScreen)if(count>src.principles.maxRunningExperimentsPerScreen)throw new Error(`too many concurrent experiments on ${screen}`)
|
|
const manifest={schemaVersion:src.schemaVersion,experimentVersion:src.experimentVersion,sourceSha256:sha,principles:src.principles,featureFlags:src.featureFlags,experiments:src.experiments}
|
|
fs.mkdirSync('generated',{recursive:true});fs.writeFileSync('generated/experiment-manifest.json',JSON.stringify(manifest,null,2)+'\n')
|
|
const ts=`// generated from ${sourcePath}; do not edit.\nexport const kbxFeatureFlags = ${JSON.stringify(Object.fromEntries(src.featureFlags.map(x=>[x.id,x])),null,2)} as const\nexport const kbxExperiments = ${JSON.stringify(Object.fromEntries(src.experiments.map(x=>[x.id,x])),null,2)} as const\nexport type KbxFeatureFlagId = keyof typeof kbxFeatureFlags\nexport type KbxExperimentId = keyof typeof kbxExperiments\nexport const kbxExperimentSourceSha256 = '${sha}' as const\n`
|
|
fs.mkdirSync('packages/kbx-contracts/src/generated',{recursive:true});fs.writeFileSync('packages/kbx-contracts/src/generated/experimentCatalog.ts',ts)
|
|
const esc=s=>s.replaceAll('"','\\"')
|
|
const csFlags=src.featureFlags.map(x=>` ["${x.id}"] = new("${x.id}", "${x.screenId}", "${x.surface}", "${x.defaultVariant}", ${x.killSwitch?'true':'false'})`).join(',\n')
|
|
const csExps=src.experiments.map(x=>` ["${x.id}"] = new("${x.id}", "${x.flagId}", "${x.screenId}", "${x.state}", ${x.rolloutPercent}, ${x.minExposurePerVariant}, new[] { ${x.variants.map(v=>`new KbxVariantWeight("${v.key}", ${v.weight})`).join(', ')} }, "${x.primaryMetric.key}", "${x.primaryMetric.direction}", ${x.primaryMetric.minimumImprovementPercent})`).join(',\n')
|
|
const cs=`// generated from ${sourcePath}; do not edit.\nnamespace Kbx.Shared.Experiments.Generated;\n\npublic sealed record KbxFeatureFlagDefinition(string Id,string ScreenId,string Surface,string DefaultVariant,bool KillSwitch);\npublic sealed record KbxVariantWeight(string Key,int Weight);\npublic sealed record KbxExperimentDefinition(string Id,string FlagId,string ScreenId,string State,int RolloutPercent,int MinExposurePerVariant,IReadOnlyList<KbxVariantWeight> Variants,string PrimaryMetric,string PrimaryDirection,double MinimumImprovementPercent);\npublic static class KbxExperimentCatalog\n{\n public const string SourceSha256 = "${sha}";\n public static readonly IReadOnlyDictionary<string,KbxFeatureFlagDefinition> FeatureFlags = new Dictionary<string,KbxFeatureFlagDefinition>(StringComparer.Ordinal)\n {\n${csFlags}\n };\n public static readonly IReadOnlyDictionary<string,KbxExperimentDefinition> Experiments = new Dictionary<string,KbxExperimentDefinition>(StringComparer.Ordinal)\n {\n${csExps}\n };\n}\n`
|
|
fs.mkdirSync('backend/Shared/Experiments/Generated',{recursive:true});fs.writeFileSync('backend/Shared/Experiments/Generated/KbxExperimentCatalog.g.cs',cs)
|
|
console.log(`generated experiment contracts: flags=${src.featureFlags.length}, experiments=${src.experiments.length}`)
|