|
| 1 | +import { assert } from './test-utils/deps-node.js'; |
| 2 | +import { EuclidianDistanceEngine, HDSModelConverters } from '../ts/index.ts'; |
| 3 | + |
| 4 | +// Load the cervical-fluid pack from data-model-draft dist |
| 5 | +import fs from 'fs'; |
| 6 | +import path from 'path'; |
| 7 | +import { fileURLToPath } from 'url'; |
| 8 | + |
| 9 | +const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 10 | +// Resolve path to data-model-draft dist (sibling repo in _macro2) |
| 11 | +const macro2Root = path.resolve(__dirname, '../../..'); |
| 12 | +const cervicalFluidPack = JSON.parse( |
| 13 | + fs.readFileSync(path.join(macro2Root, 'data-model-draft/data-model-draft/dist/converters/cervical-fluid/pack-latest.json'), 'utf-8') |
| 14 | +); |
| 15 | +const moodPack = JSON.parse( |
| 16 | + fs.readFileSync(path.join(macro2Root, 'data-model-draft/data-model-draft/dist/converters/mood/pack-latest.json'), 'utf-8') |
| 17 | +); |
| 18 | + |
| 19 | +describe('[EDEX] EuclidianDistanceEngine', function () { |
| 20 | + let cfEngine; |
| 21 | + let moodEngine; |
| 22 | + |
| 23 | + before(() => { |
| 24 | + cfEngine = new EuclidianDistanceEngine(cervicalFluidPack); |
| 25 | + moodEngine = new EuclidianDistanceEngine(moodPack); |
| 26 | + }); |
| 27 | + |
| 28 | + describe('[EDEI] Engine initialization', () => { |
| 29 | + it('[EDEI1] cervical-fluid engine loads 11 methods', () => { |
| 30 | + assert.strictEqual(cfEngine.methodIds.length, 11); |
| 31 | + }); |
| 32 | + |
| 33 | + it('[EDEI2] mood engine loads 2 methods', () => { |
| 34 | + assert.strictEqual(moodEngine.methodIds.length, 2); |
| 35 | + }); |
| 36 | + |
| 37 | + it('[EDEI3] cervical-fluid has 9 dimensions', () => { |
| 38 | + assert.strictEqual(cfEngine.dimensionNames.length, 9); |
| 39 | + }); |
| 40 | + |
| 41 | + it('[EDEI4] mood has 5 dimensions', () => { |
| 42 | + assert.strictEqual(moodEngine.dimensionNames.length, 5); |
| 43 | + }); |
| 44 | + |
| 45 | + it('[EDEI5] weights sum to 1.0', () => { |
| 46 | + let cfSum = 0; |
| 47 | + for (const d of cfEngine.dimensionNames) cfSum += cfEngine.weights[d]; |
| 48 | + assert.ok(Math.abs(cfSum - 1.0) < 0.01); |
| 49 | + |
| 50 | + let moodSum = 0; |
| 51 | + for (const d of moodEngine.dimensionNames) moodSum += moodEngine.weights[d]; |
| 52 | + assert.ok(Math.abs(moodSum - 1.0) < 0.01); |
| 53 | + }); |
| 54 | + }); |
| 55 | + |
| 56 | + describe('[EDEV] toVector / fromVector', () => { |
| 57 | + it('[EDEV1] appleHealth eggWhite → vector with high values', () => { |
| 58 | + const vec = cfEngine.toVector('appleHealth', 'eggWhite'); |
| 59 | + assert.strictEqual(vec.threadiness, 1); |
| 60 | + assert.strictEqual(vec.stretchability, 1); |
| 61 | + assert.strictEqual(vec.lubricative, 1); |
| 62 | + }); |
| 63 | + |
| 64 | + it('[EDEV2] appleHealth dry → zero vector', () => { |
| 65 | + const vec = cfEngine.toVector('appleHealth', 'dry'); |
| 66 | + let sum = 0; |
| 67 | + for (const d of cfEngine.dimensionNames) sum += vec[d]; |
| 68 | + assert.strictEqual(sum, 0); |
| 69 | + }); |
| 70 | + |
| 71 | + it('[EDEV3] fromVector round-trips appleHealth observations', () => { |
| 72 | + for (const obs of ['dry', 'sticky', 'creamy', 'watery', 'eggWhite']) { |
| 73 | + const vec = cfEngine.toVector('appleHealth', obs); |
| 74 | + const result = cfEngine.fromVector('appleHealth', vec); |
| 75 | + assert.strictEqual(result.data, obs, `round-trip failed for ${obs}`); |
| 76 | + assert.strictEqual(result.matchDistance, 0, `distance should be 0 for exact match: ${obs}`); |
| 77 | + } |
| 78 | + }); |
| 79 | + |
| 80 | + it('[EDEV4] mira mood observations round-trip', () => { |
| 81 | + for (const obs of ['Happy', 'Sad', 'Normal', 'Excited']) { |
| 82 | + const vec = moodEngine.toVector('mira', obs); |
| 83 | + const result = moodEngine.fromVector('mira', vec); |
| 84 | + assert.strictEqual(result.data, obs, `round-trip failed for ${obs}`); |
| 85 | + } |
| 86 | + }); |
| 87 | + |
| 88 | + it('[EDEV5] case-insensitive matching for mira cervical-fluid', () => { |
| 89 | + const vec = cfEngine.toVector('mira', 'raw egg white'); |
| 90 | + assert.strictEqual(vec.threadiness, 1); |
| 91 | + }); |
| 92 | + |
| 93 | + it('[EDEV6] unknown observation throws', () => { |
| 94 | + assert.throws(() => cfEngine.toVector('appleHealth', 'unknown'), /Unknown/); |
| 95 | + }); |
| 96 | + |
| 97 | + it('[EDEV7] unknown method throws', () => { |
| 98 | + assert.throws(() => cfEngine.toVector('nonexistent', 'dry'), /Unknown method/); |
| 99 | + }); |
| 100 | + }); |
| 101 | + |
| 102 | + describe('[EDEC] Cross-method conversion', () => { |
| 103 | + it('[EDEC1] mira Raw Egg White → appleHealth eggWhite', () => { |
| 104 | + const result = cfEngine.convertMethodToMethod('mira', 'appleHealth', 'Raw Egg White'); |
| 105 | + assert.strictEqual(result.data, 'eggWhite'); |
| 106 | + assert.strictEqual(result.matchDistance, 0); |
| 107 | + }); |
| 108 | + |
| 109 | + it('[EDEC2] mira Creamy → appleHealth creamy', () => { |
| 110 | + const result = cfEngine.convertMethodToMethod('mira', 'appleHealth', 'Creamy'); |
| 111 | + assert.strictEqual(result.data, 'creamy'); |
| 112 | + }); |
| 113 | + |
| 114 | + it('[EDEC3] mira Dry → appleHealth dry', () => { |
| 115 | + const result = cfEngine.convertMethodToMethod('mira', 'appleHealth', 'Dry'); |
| 116 | + assert.strictEqual(result.data, 'dry'); |
| 117 | + }); |
| 118 | + |
| 119 | + it('[EDEC4] creighton 10KL → appleHealth eggWhite', () => { |
| 120 | + const result = cfEngine.convertMethodToMethod('creighton', 'appleHealth', '10KL'); |
| 121 | + assert.strictEqual(result.data, 'eggWhite'); |
| 122 | + }); |
| 123 | + |
| 124 | + it('[EDEC5] creighton 0 → appleHealth dry', () => { |
| 125 | + const result = cfEngine.convertMethodToMethod('creighton', 'appleHealth', '0'); |
| 126 | + assert.strictEqual(result.data, 'dry'); |
| 127 | + }); |
| 128 | + |
| 129 | + it('[EDEC6] mira Clumpy white → appleHealth sticky (pathological)', () => { |
| 130 | + const result = cfEngine.convertMethodToMethod('mira', 'appleHealth', 'Clumpy white'); |
| 131 | + assert.ok(['dry', 'sticky'].includes(result.data), `expected dry or sticky, got ${result.data}`); |
| 132 | + }); |
| 133 | + |
| 134 | + it('[EDEC7] mood mira Happy → hds produces valid 5D vector', () => { |
| 135 | + const vec = moodEngine.toVector('mira', 'Happy'); |
| 136 | + assert.strictEqual(vec.valence, 0.80); |
| 137 | + assert.strictEqual(vec.arousal, 0.60); |
| 138 | + const result = moodEngine.fromVector('hds', vec); |
| 139 | + // hds is assembly — result should be an object with 5 fields |
| 140 | + assert.ok(result.data, 'should have result data'); |
| 141 | + }); |
| 142 | + }); |
| 143 | + |
| 144 | + describe('[EDED] Distance calculations', () => { |
| 145 | + it('[EDED1] distance between identical vectors is 0', () => { |
| 146 | + const vec = cfEngine.toVector('appleHealth', 'creamy'); |
| 147 | + assert.strictEqual(cfEngine.distance(vec, vec), 0); |
| 148 | + }); |
| 149 | + |
| 150 | + it('[EDED2] distance between dry and eggWhite is large', () => { |
| 151 | + const dry = cfEngine.toVector('appleHealth', 'dry'); |
| 152 | + const egg = cfEngine.toVector('appleHealth', 'eggWhite'); |
| 153 | + const d = cfEngine.distance(dry, egg); |
| 154 | + assert.ok(d > 0.5, `expected large distance, got ${d}`); |
| 155 | + }); |
| 156 | + |
| 157 | + it('[EDED3] distance between sticky and creamy is small', () => { |
| 158 | + const sticky = cfEngine.toVector('appleHealth', 'sticky'); |
| 159 | + const creamy = cfEngine.toVector('appleHealth', 'creamy'); |
| 160 | + const d = cfEngine.distance(sticky, creamy); |
| 161 | + assert.ok(d < 0.2, `expected small distance, got ${d}`); |
| 162 | + }); |
| 163 | + |
| 164 | + it('[EDED4] zeroVector has all zeros', () => { |
| 165 | + const z = cfEngine.zeroVector(); |
| 166 | + for (const dim of cfEngine.dimensionNames) { |
| 167 | + assert.strictEqual(z[dim], 0); |
| 168 | + } |
| 169 | + }); |
| 170 | + }); |
| 171 | +}); |
| 172 | + |
| 173 | +describe('[MCVX] HDSModelConverters with loadPack', function () { |
| 174 | + let converters; |
| 175 | + |
| 176 | + before(() => { |
| 177 | + // Create a minimal mock model |
| 178 | + const mockModel = { |
| 179 | + modelUrl: 'https://model.datasafe.dev/pack.json', |
| 180 | + modelData: { |
| 181 | + items: { |
| 182 | + 'body-vulva-mucus-inspect': { |
| 183 | + key: 'body-vulva-mucus-inspect', |
| 184 | + streamId: 'body-vulva-mucus-inspect', |
| 185 | + eventType: 'vulva-mucus-inspect/9d-vector', |
| 186 | + type: 'convertible', |
| 187 | + 'converter-engine': { key: 'euclidian-distance', version: 'v0', models: 'cervical-fluid' } |
| 188 | + }, |
| 189 | + 'wellbeing-mood': { |
| 190 | + key: 'wellbeing-mood', |
| 191 | + streamId: 'wellbeing-mood', |
| 192 | + eventType: 'mood/5d-vectors', |
| 193 | + type: 'convertible', |
| 194 | + 'converter-engine': { key: 'euclidian-distance', version: 'v0', models: 'mood' } |
| 195 | + } |
| 196 | + }, |
| 197 | + converters: { |
| 198 | + 'cervical-fluid': { latestVersion: 'v0' }, |
| 199 | + mood: { latestVersion: 'v0' } |
| 200 | + } |
| 201 | + } |
| 202 | + }; |
| 203 | + |
| 204 | + converters = new HDSModelConverters(mockModel); |
| 205 | + converters.loadPack(cervicalFluidPack); |
| 206 | + converters.loadPack(moodPack); |
| 207 | + }); |
| 208 | + |
| 209 | + it('[MCVX1] lists loaded item keys', () => { |
| 210 | + const keys = converters.loadedItemKeys.sort(); |
| 211 | + assert.deepStrictEqual(keys, ['cervical-fluid', 'mood']); |
| 212 | + }); |
| 213 | + |
| 214 | + it('[MCVX2] getEngine returns loaded engines', () => { |
| 215 | + assert.ok(converters.getEngine('cervical-fluid')); |
| 216 | + assert.ok(converters.getEngine('mood')); |
| 217 | + assert.strictEqual(converters.getEngine('unknown'), undefined); |
| 218 | + }); |
| 219 | + |
| 220 | + it('[MCVX3] convertMethodToEvent produces valid event structure', async () => { |
| 221 | + const event = await converters.convertMethodToEvent('cervical-fluid', 'mira', 'Creamy'); |
| 222 | + assert.strictEqual(event.type, 'vulva-mucus-inspect/9d-vector'); |
| 223 | + assert.deepStrictEqual(event.streamIds, ['body-vulva-mucus-inspect']); |
| 224 | + assert.ok(event.content.data, 'should have data'); |
| 225 | + assert.strictEqual(event.content.data.threadiness, 0.4); |
| 226 | + assert.ok(event.content.source, 'should have source'); |
| 227 | + assert.strictEqual(event.content.source.key, 'mira'); |
| 228 | + assert.strictEqual(event.content.source.sourceData, 'Creamy'); |
| 229 | + assert.strictEqual(event.content.source.engineVersion, 'v0'); |
| 230 | + }); |
| 231 | + |
| 232 | + it('[MCVX4] convertEventToMethod recovers source observation', async () => { |
| 233 | + const event = await converters.convertMethodToEvent('cervical-fluid', 'appleHealth', 'eggWhite'); |
| 234 | + const result = await converters.convertEventToMethod(event, 'appleHealth'); |
| 235 | + assert.strictEqual(result.data, 'eggWhite'); |
| 236 | + assert.strictEqual(result.matchDistance, 0); |
| 237 | + }); |
| 238 | + |
| 239 | + it('[MCVX5] convertEventToMethod cross-method', async () => { |
| 240 | + const event = await converters.convertMethodToEvent('cervical-fluid', 'mira', 'Raw Egg White'); |
| 241 | + const result = await converters.convertEventToMethod(event, 'appleHealth'); |
| 242 | + assert.strictEqual(result.data, 'eggWhite'); |
| 243 | + }); |
| 244 | + |
| 245 | + it('[MCVX6] convertMethodToMethod works', async () => { |
| 246 | + const result = await converters.convertMethodToMethod('mood', 'mira', 'hds', 'Happy'); |
| 247 | + assert.ok(result.data, 'should have result'); |
| 248 | + assert.ok(result.matchDistance >= 0, 'should have matchDistance'); |
| 249 | + }); |
| 250 | + |
| 251 | + it('[MCVX7] convertMethodToEvent for mood produces valid event', async () => { |
| 252 | + const event = await converters.convertMethodToEvent('mood', 'mira', 'Sad'); |
| 253 | + assert.strictEqual(event.type, 'mood/5d-vectors'); |
| 254 | + assert.deepStrictEqual(event.streamIds, ['wellbeing-mood']); |
| 255 | + assert.strictEqual(event.content.data.valence, 0.15); |
| 256 | + assert.strictEqual(event.content.source.key, 'mira'); |
| 257 | + assert.strictEqual(event.content.source.sourceData, 'Sad'); |
| 258 | + }); |
| 259 | + |
| 260 | + it('[MCVX8] throws for unknown item key', async () => { |
| 261 | + try { |
| 262 | + await converters.convertMethodToEvent('unknown', 'mira', 'test'); |
| 263 | + assert.fail('should have thrown'); |
| 264 | + } catch (e) { |
| 265 | + assert.ok(e.message.includes('Unknown converter item key')); |
| 266 | + } |
| 267 | + }); |
| 268 | + |
| 269 | + it('[MCVX9] loadPack rejects unknown engine', () => { |
| 270 | + assert.throws(() => { |
| 271 | + converters.loadPack({ engine: 'neural-network', itemKey: 'test', methods: [] }); |
| 272 | + }, /Unknown converter engine/); |
| 273 | + }); |
| 274 | +}); |
0 commit comments