549 lines
18 KiB
JavaScript
549 lines
18 KiB
JavaScript
/**
|
||
* shipResolution.test.js
|
||
* Tests the resolution pipeline, calibration, archetype/weights, parlay,
|
||
* capper content, and migration SQL for the VYNDR ship build.
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
// ---------- Migration SQL ----------
|
||
const sql005 = fs.readFileSync(path.join(__dirname, '../../supabase/migrations/005_lineup_scheme_data.sql'), 'utf8');
|
||
const sql006 = fs.readFileSync(path.join(__dirname, '../../supabase/migrations/006_data_warehouse_calibration.sql'), 'utf8');
|
||
const sql007 = fs.readFileSync(path.join(__dirname, '../../supabase/migrations/007_lineup_odds_trust_health.sql'), 'utf8');
|
||
|
||
// ==========================================================
|
||
// Helpers — inline logic that mirrors production functions
|
||
// ==========================================================
|
||
|
||
function resolveHit(direction, propLine, actualValue) {
|
||
if (direction === 'over') return actualValue > propLine;
|
||
if (direction === 'under') return actualValue < propLine;
|
||
return null;
|
||
}
|
||
|
||
function calculateCLV(openingLine, closingLine, direction) {
|
||
if (openingLine == null || closingLine == null) return null;
|
||
const movement = closingLine - openingLine;
|
||
const favorable = direction === 'over' ? movement > 0 : movement < 0;
|
||
return {
|
||
clv: favorable ? Math.abs(movement) : -Math.abs(movement),
|
||
clv_magnitude: Math.abs(movement),
|
||
};
|
||
}
|
||
|
||
function modelMarketAlignment(openingLine, closingLine, direction) {
|
||
if (openingLine == null || closingLine == null) return null;
|
||
const movement = closingLine - openingLine;
|
||
const marketMovedWithUs =
|
||
(direction === 'over' && movement > 0) ||
|
||
(direction === 'under' && movement < 0);
|
||
return marketMovedWithUs ? 'confirming' : 'contrarian';
|
||
}
|
||
|
||
function createJointOutcomes(grades) {
|
||
const resolved = grades.filter((g) => g.hit != null);
|
||
const pairs = [];
|
||
for (let i = 0; i < resolved.length; i++) {
|
||
for (let j = i + 1; j < resolved.length; j++) {
|
||
pairs.push({
|
||
player_a_id: resolved[i].player_id,
|
||
player_b_id: resolved[j].player_id,
|
||
stat_a: resolved[i].stat_type,
|
||
stat_b: resolved[j].stat_type,
|
||
hit_a: resolved[i].hit,
|
||
hit_b: resolved[j].hit,
|
||
});
|
||
}
|
||
}
|
||
return pairs;
|
||
}
|
||
|
||
function shouldRecalibrate(sampleSize) {
|
||
return [25, 50, 75, 100].includes(sampleSize);
|
||
}
|
||
|
||
function clampOffset(raw) {
|
||
return Math.max(-0.15, Math.min(0.15, raw));
|
||
}
|
||
|
||
function brierScore(confidence, hit) {
|
||
return (confidence - (hit ? 1 : 0)) ** 2;
|
||
}
|
||
|
||
function detectBlindSpots(grades) {
|
||
if (grades.length < 200) return [];
|
||
const grouped = {};
|
||
for (const g of grades) {
|
||
const key = `${g.stat_type}|${g.context}`;
|
||
if (!grouped[key]) grouped[key] = { total: 0, hits: 0 };
|
||
grouped[key].total += 1;
|
||
if (g.hit) grouped[key].hits += 1;
|
||
}
|
||
const overall = grades.filter((g) => g.hit).length / grades.length;
|
||
const spots = [];
|
||
for (const [key, val] of Object.entries(grouped)) {
|
||
const rate = val.hits / val.total;
|
||
if ((overall - rate) / overall >= 0.25) {
|
||
spots.push({ key, rate, degradation: (overall - rate) / overall });
|
||
}
|
||
}
|
||
return spots;
|
||
}
|
||
|
||
function catastrophicMisses(grades) {
|
||
const resolved = grades.filter((g) => g.hit === false && g.actual_value != null);
|
||
resolved.sort((a, b) => Math.abs(b.actual_value - b.prop_line) - Math.abs(a.actual_value - a.prop_line));
|
||
const cutoff = Math.max(5, Math.ceil(resolved.length * 0.05));
|
||
return resolved.slice(0, cutoff);
|
||
}
|
||
|
||
// NBA archetype detection & weight blending
|
||
const ARCHETYPE_WEIGHTS = {
|
||
primary_scorer: { matchup_defense: 0.30, usage_context: 0.25, recent_form: 0.20, pace_impact: 0.15, rest_travel: 0.10 },
|
||
secondary_creator: { usage_context: 0.35, matchup_defense: 0.20, recent_form: 0.20, pace_impact: 0.15, rest_travel: 0.10 },
|
||
stretch_big: { matchup_defense: 0.25, usage_context: 0.20, recent_form: 0.20, pace_impact: 0.20, rest_travel: 0.15 },
|
||
default: { matchup_defense: 0.20, usage_context: 0.20, recent_form: 0.20, pace_impact: 0.20, rest_travel: 0.20 },
|
||
};
|
||
|
||
function detectArchetypes(profile) {
|
||
const scores = {};
|
||
if (profile.pts_per_game >= 22 && profile.usage_rate >= 0.28) {
|
||
scores.primary_scorer = (profile.pts_per_game / 30) * 0.5 + (profile.usage_rate / 0.35) * 0.5;
|
||
}
|
||
if (profile.ast_per_game >= 5 && profile.usage_rate >= 0.20) {
|
||
scores.secondary_creator = (profile.ast_per_game / 10) * 0.5 + (profile.usage_rate / 0.30) * 0.5;
|
||
}
|
||
if (profile.reb_per_game >= 7 && profile.three_pa_rate >= 0.25) {
|
||
scores.stretch_big = (profile.reb_per_game / 12) * 0.5 + (profile.three_pa_rate / 0.40) * 0.5;
|
||
}
|
||
return scores;
|
||
}
|
||
|
||
function blendWeights(archetypeScores) {
|
||
const entries = Object.entries(archetypeScores).filter(([, s]) => s >= 0.1);
|
||
if (entries.length === 0) return { ...ARCHETYPE_WEIGHTS.default };
|
||
|
||
const totalScore = entries.reduce((s, [, v]) => s + v, 0);
|
||
const blended = {};
|
||
for (const [arch, score] of entries) {
|
||
const proportion = score / totalScore;
|
||
const aw = ARCHETYPE_WEIGHTS[arch];
|
||
for (const [dim, w] of Object.entries(aw)) {
|
||
blended[dim] = (blended[dim] || 0) + w * proportion;
|
||
}
|
||
}
|
||
return blended;
|
||
}
|
||
|
||
// Parlay helpers
|
||
function isSameGame(legs) {
|
||
const gameIds = legs.map((l) => l.game_id);
|
||
return new Set(gameIds).size === 1;
|
||
}
|
||
|
||
function structuralPenalty(legCount) {
|
||
return legCount > 2 ? (legCount - 2) * 0.03 : 0;
|
||
}
|
||
|
||
function canComputePhi(jointCount) {
|
||
return jointCount >= 30;
|
||
}
|
||
|
||
// Capper content formatters
|
||
function formatCapperPick(pick) {
|
||
return `VYNDR Scan #${pick.number}: ${pick.player} ${pick.direction} ${pick.line} ${pick.stat}`;
|
||
}
|
||
|
||
function formatBreakingAlert(alert) {
|
||
return `BREAKING: ${alert.player} — ${alert.message}`;
|
||
}
|
||
|
||
function formatDailyResults(results) {
|
||
return results.map((r) => `${r.hit ? '✅' : '❌'} ${r.player} ${r.stat} ${r.line}`).join('\n');
|
||
}
|
||
|
||
function formatMissAutopsy(miss) {
|
||
return `${miss.player} ${miss.stat} ${miss.line}\nWhy: ${miss.reason}`;
|
||
}
|
||
|
||
// ==========================================================
|
||
// TESTS
|
||
// ==========================================================
|
||
|
||
// ---- 1. Resolution hit/miss ----
|
||
describe('Resolution hit/miss', () => {
|
||
test('over hit when actual > line', () => {
|
||
expect(resolveHit('over', 22.5, 28)).toBe(true);
|
||
});
|
||
|
||
test('over miss when actual < line', () => {
|
||
expect(resolveHit('over', 22.5, 18)).toBe(false);
|
||
});
|
||
|
||
test('under hit when actual < line', () => {
|
||
expect(resolveHit('under', 22.5, 18)).toBe(true);
|
||
});
|
||
|
||
test('under miss when actual > line', () => {
|
||
expect(resolveHit('under', 22.5, 28)).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ---- 2. CLV calculation ----
|
||
describe('CLV calculation', () => {
|
||
test('positive CLV when closing moves toward our direction', () => {
|
||
const result = calculateCLV(22.5, 24.0, 'over');
|
||
expect(result.clv).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('negative CLV when closing moves against our direction', () => {
|
||
const result = calculateCLV(22.5, 20.0, 'over');
|
||
expect(result.clv).toBeLessThan(0);
|
||
});
|
||
|
||
test('null when no odds data', () => {
|
||
expect(calculateCLV(null, null, 'over')).toBeNull();
|
||
});
|
||
|
||
test('clv_magnitude is abs(movement)', () => {
|
||
const result = calculateCLV(22.5, 20.0, 'over');
|
||
expect(result.clv_magnitude).toBe(2.5);
|
||
});
|
||
});
|
||
|
||
// ---- 3. Model-market alignment ----
|
||
describe('Model-market alignment', () => {
|
||
test('confirming when market moves with us (over)', () => {
|
||
expect(modelMarketAlignment(22.5, 24.0, 'over')).toBe('confirming');
|
||
});
|
||
|
||
test('contrarian when market moves against us', () => {
|
||
expect(modelMarketAlignment(22.5, 20.0, 'over')).toBe('contrarian');
|
||
});
|
||
|
||
test('null when no data', () => {
|
||
expect(modelMarketAlignment(null, null, 'over')).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ---- 4. Joint outcome logging ----
|
||
describe('Joint outcome logging', () => {
|
||
test('creates pair for same-game grades', () => {
|
||
const grades = [
|
||
{ player_id: 'A', stat_type: 'pts', hit: true },
|
||
{ player_id: 'B', stat_type: 'reb', hit: false },
|
||
];
|
||
const pairs = createJointOutcomes(grades);
|
||
expect(pairs).toHaveLength(1);
|
||
expect(pairs[0].player_a_id).toBe('A');
|
||
expect(pairs[0].player_b_id).toBe('B');
|
||
});
|
||
|
||
test('skips self-pair', () => {
|
||
const grades = [{ player_id: 'A', stat_type: 'pts', hit: true }];
|
||
const pairs = createJointOutcomes(grades);
|
||
expect(pairs).toHaveLength(0);
|
||
});
|
||
|
||
test('skips unresolved pairs', () => {
|
||
const grades = [
|
||
{ player_id: 'A', stat_type: 'pts', hit: true },
|
||
{ player_id: 'B', stat_type: 'reb', hit: null },
|
||
];
|
||
const pairs = createJointOutcomes(grades);
|
||
expect(pairs).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
// ---- 5. Calibration thresholds ----
|
||
describe('Calibration thresholds', () => {
|
||
test('triggers recalibration at 25, 50, 75, 100', () => {
|
||
expect(shouldRecalibrate(25)).toBe(true);
|
||
expect(shouldRecalibrate(50)).toBe(true);
|
||
expect(shouldRecalibrate(75)).toBe(true);
|
||
expect(shouldRecalibrate(100)).toBe(true);
|
||
expect(shouldRecalibrate(30)).toBe(false);
|
||
});
|
||
|
||
test('point-biserial correlation bounds 0.05–0.50', () => {
|
||
const lower = 0.05;
|
||
const upper = 0.50;
|
||
const validCorrelation = 0.22;
|
||
expect(validCorrelation).toBeGreaterThanOrEqual(lower);
|
||
expect(validCorrelation).toBeLessThanOrEqual(upper);
|
||
expect(0.01).toBeLessThan(lower);
|
||
expect(0.55).toBeGreaterThan(upper);
|
||
});
|
||
|
||
test('global offset thresholds at 100/250/500/1000', () => {
|
||
const thresholds = [100, 250, 500, 1000];
|
||
expect(thresholds).toContain(100);
|
||
expect(thresholds).toContain(250);
|
||
expect(thresholds).toContain(500);
|
||
expect(thresholds).toContain(1000);
|
||
});
|
||
});
|
||
|
||
// ---- 6. Global offset clamp ----
|
||
describe('Global offset clamp', () => {
|
||
test('clamped to 0.15 max', () => {
|
||
expect(clampOffset(0.30)).toBe(0.15);
|
||
});
|
||
|
||
test('clamped to -0.15 min', () => {
|
||
expect(clampOffset(-0.25)).toBe(-0.15);
|
||
});
|
||
});
|
||
|
||
// ---- 7. Brier score update ----
|
||
describe('Brier score', () => {
|
||
test('Brier = 0.0 for perfect prediction', () => {
|
||
expect(brierScore(1.0, true)).toBeCloseTo(0.0);
|
||
});
|
||
|
||
test('Brier = 0.25 for coin flip', () => {
|
||
expect(brierScore(0.5, true)).toBeCloseTo(0.25);
|
||
});
|
||
});
|
||
|
||
// ---- 8. Blind spot detection ----
|
||
describe('Blind spot detection', () => {
|
||
test('requires 200+ grades minimum', () => {
|
||
const grades = Array.from({ length: 150 }, (_, i) => ({
|
||
stat_type: 'pts', context: 'home', hit: i % 2 === 0,
|
||
}));
|
||
expect(detectBlindSpots(grades)).toEqual([]);
|
||
});
|
||
|
||
test('flags 25%+ degradation', () => {
|
||
// 200 grades: 140 hit overall (70%), but stat_type=reb|away: 10 total, 3 hit (30%) → degradation = (0.7-0.3)/0.7 = 0.57
|
||
const grades = [];
|
||
for (let i = 0; i < 190; i++) {
|
||
grades.push({ stat_type: 'pts', context: 'home', hit: i < 137 });
|
||
}
|
||
for (let i = 0; i < 10; i++) {
|
||
grades.push({ stat_type: 'reb', context: 'away', hit: i < 3 });
|
||
}
|
||
const spots = detectBlindSpots(grades);
|
||
expect(spots.length).toBeGreaterThanOrEqual(1);
|
||
expect(spots[0].degradation).toBeGreaterThanOrEqual(0.25);
|
||
});
|
||
|
||
test('returns empty below threshold', () => {
|
||
const grades = Array.from({ length: 199 }, () => ({
|
||
stat_type: 'pts', context: 'home', hit: true,
|
||
}));
|
||
expect(detectBlindSpots(grades)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// ---- 9. Catastrophic miss tracking ----
|
||
describe('Catastrophic miss tracking', () => {
|
||
test('finds worst 5%', () => {
|
||
const grades = [];
|
||
for (let i = 0; i < 200; i++) {
|
||
grades.push({
|
||
hit: false,
|
||
actual_value: 10 + i,
|
||
prop_line: 5,
|
||
});
|
||
}
|
||
const misses = catastrophicMisses(grades);
|
||
expect(misses.length).toBe(10); // 5% of 200
|
||
});
|
||
|
||
test('minimum 5 misses returned', () => {
|
||
const grades = [];
|
||
for (let i = 0; i < 20; i++) {
|
||
grades.push({ hit: false, actual_value: 30 + i, prop_line: 10 });
|
||
}
|
||
const misses = catastrophicMisses(grades);
|
||
expect(misses.length).toBeGreaterThanOrEqual(5);
|
||
});
|
||
|
||
test('sorted by abs_error descending', () => {
|
||
const grades = [
|
||
{ hit: false, actual_value: 50, prop_line: 20 },
|
||
{ hit: false, actual_value: 25, prop_line: 20 },
|
||
{ hit: false, actual_value: 40, prop_line: 20 },
|
||
];
|
||
const misses = catastrophicMisses(grades);
|
||
const errors = misses.map((m) => Math.abs(m.actual_value - m.prop_line));
|
||
for (let i = 1; i < errors.length; i++) {
|
||
expect(errors[i - 1]).toBeGreaterThanOrEqual(errors[i]);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ---- 10. NBA archetype weight blending ----
|
||
describe('NBA archetype weight blending', () => {
|
||
test('primary_scorer has matchup_defense at 0.30', () => {
|
||
expect(ARCHETYPE_WEIGHTS.primary_scorer.matchup_defense).toBe(0.30);
|
||
});
|
||
|
||
test('secondary_creator has usage_context at 0.35', () => {
|
||
expect(ARCHETYPE_WEIGHTS.secondary_creator.usage_context).toBe(0.35);
|
||
});
|
||
|
||
test('default weights when all archetype scores < 0.1', () => {
|
||
const weights = blendWeights({ primary_scorer: 0.05, secondary_creator: 0.02 });
|
||
expect(weights).toEqual(ARCHETYPE_WEIGHTS.default);
|
||
});
|
||
|
||
test('blending with multiple archetypes produces proportional mix', () => {
|
||
const scores = { primary_scorer: 0.6, secondary_creator: 0.4 };
|
||
const weights = blendWeights(scores);
|
||
// primary proportion = 0.6, secondary = 0.4, total = 1.0
|
||
const expectedMatchupDefense = 0.30 * 0.6 + 0.20 * 0.4;
|
||
expect(weights.matchup_defense).toBeCloseTo(expectedMatchupDefense);
|
||
});
|
||
|
||
test('weights sum to ~1.0', () => {
|
||
const scores = { primary_scorer: 0.8, secondary_creator: 0.5 };
|
||
const weights = blendWeights(scores);
|
||
const sum = Object.values(weights).reduce((a, b) => a + b, 0);
|
||
expect(sum).toBeCloseTo(1.0, 2);
|
||
});
|
||
|
||
test('stretch_big detected from reb_per_game + three_pa_rate', () => {
|
||
const profile = {
|
||
pts_per_game: 14, usage_rate: 0.18, ast_per_game: 2,
|
||
reb_per_game: 9.5, three_pa_rate: 0.32,
|
||
};
|
||
const archetypes = detectArchetypes(profile);
|
||
expect(archetypes).toHaveProperty('stretch_big');
|
||
expect(archetypes.stretch_big).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
|
||
// ---- 11. Parlay correlation ----
|
||
describe('Parlay correlation', () => {
|
||
test('same-game detected', () => {
|
||
const legs = [
|
||
{ game_id: 'G1', player: 'A' },
|
||
{ game_id: 'G1', player: 'B' },
|
||
];
|
||
expect(isSameGame(legs)).toBe(true);
|
||
});
|
||
|
||
test('structural penalty 0.03 per extra leg beyond 2', () => {
|
||
expect(structuralPenalty(2)).toBe(0);
|
||
expect(structuralPenalty(3)).toBeCloseTo(0.03);
|
||
expect(structuralPenalty(5)).toBeCloseTo(0.09);
|
||
});
|
||
|
||
test('phi requires 30+ joints', () => {
|
||
expect(canComputePhi(29)).toBe(false);
|
||
expect(canComputePhi(30)).toBe(true);
|
||
});
|
||
|
||
test('warning on 4+ legs', () => {
|
||
const legCount = 4;
|
||
const warning = legCount >= 4 ? 'High-leg parlay — correlation risk elevated' : null;
|
||
expect(warning).not.toBeNull();
|
||
expect(warning).toContain('correlation');
|
||
});
|
||
});
|
||
|
||
// ---- 12. Capper content ----
|
||
describe('Capper content', () => {
|
||
test('pick number increments', () => {
|
||
const pick1 = formatCapperPick({ number: 1, player: 'Tatum', direction: 'over', line: 27.5, stat: 'pts' });
|
||
const pick2 = formatCapperPick({ number: 2, player: 'Brown', direction: 'under', line: 5.5, stat: 'ast' });
|
||
expect(pick1).toContain('#1');
|
||
expect(pick2).toContain('#2');
|
||
});
|
||
|
||
test('breaking alert format includes BREAKING', () => {
|
||
const alert = formatBreakingAlert({ player: 'Embiid', message: 'ruled out' });
|
||
expect(alert).toContain('BREAKING');
|
||
});
|
||
|
||
test('standard format includes VYNDR Scan', () => {
|
||
const pick = formatCapperPick({ number: 5, player: 'Jokic', direction: 'over', line: 11.5, stat: 'reb' });
|
||
expect(pick).toContain('VYNDR Scan');
|
||
});
|
||
|
||
test('daily results format includes hit/miss icons', () => {
|
||
const results = formatDailyResults([
|
||
{ hit: true, player: 'LeBron', stat: 'pts', line: 25.5 },
|
||
{ hit: false, player: 'AD', stat: 'reb', line: 10.5 },
|
||
]);
|
||
expect(results).toContain('✅');
|
||
expect(results).toContain('❌');
|
||
});
|
||
|
||
test('miss autopsy includes Why:', () => {
|
||
const autopsy = formatMissAutopsy({
|
||
player: 'Harden', stat: 'ast', line: 9.5,
|
||
reason: 'Early foul trouble limited minutes',
|
||
});
|
||
expect(autopsy).toContain('Why:');
|
||
});
|
||
});
|
||
|
||
// ---- 13. Migration 005 SQL ----
|
||
describe('Migration 005 SQL', () => {
|
||
test('creates lineup_scheme_data', () => {
|
||
expect(sql005).toContain('CREATE TABLE IF NOT EXISTS lineup_scheme_data');
|
||
});
|
||
|
||
test('has RLS', () => {
|
||
expect(sql005).toContain('ENABLE ROW LEVEL SECURITY');
|
||
});
|
||
|
||
test('has indexes', () => {
|
||
expect(sql005).toContain('CREATE INDEX IF NOT EXISTS idx_ls_team');
|
||
expect(sql005).toContain('CREATE INDEX IF NOT EXISTS idx_ls_hash');
|
||
expect(sql005).toContain('CREATE INDEX IF NOT EXISTS idx_ls_date');
|
||
});
|
||
});
|
||
|
||
// ---- 14. Migration 006 SQL ----
|
||
describe('Migration 006 SQL', () => {
|
||
test('creates grade_outcomes with discipline_score column', () => {
|
||
expect(sql006).toContain('CREATE TABLE IF NOT EXISTS grade_outcomes');
|
||
expect(sql006).toContain('discipline_score DECIMAL');
|
||
});
|
||
|
||
test('creates nba_data_cache', () => {
|
||
expect(sql006).toContain('CREATE TABLE IF NOT EXISTS nba_data_cache');
|
||
});
|
||
|
||
test('creates player_calibrated_weights', () => {
|
||
expect(sql006).toContain('CREATE TABLE IF NOT EXISTS player_calibrated_weights');
|
||
});
|
||
|
||
test('grade_outcomes has clv columns', () => {
|
||
expect(sql006).toContain('clv_opening_line DECIMAL');
|
||
expect(sql006).toContain('clv_closing_line DECIMAL');
|
||
expect(sql006).toContain('clv_movement DECIMAL');
|
||
expect(sql006).toContain('clv_win BOOLEAN');
|
||
});
|
||
});
|
||
|
||
// ---- 15. Migration 007 SQL ----
|
||
describe('Migration 007 SQL', () => {
|
||
test('creates reporter_trust with source_type', () => {
|
||
expect(sql007).toContain('CREATE TABLE IF NOT EXISTS reporter_trust');
|
||
expect(sql007).toContain('source_type TEXT NOT NULL');
|
||
});
|
||
|
||
test('creates odds_warehouse', () => {
|
||
expect(sql007).toContain('CREATE TABLE IF NOT EXISTS odds_warehouse');
|
||
});
|
||
|
||
test('creates reporter_line_correlation', () => {
|
||
expect(sql007).toContain('CREATE TABLE IF NOT EXISTS reporter_line_correlation');
|
||
});
|
||
|
||
test('creates ship_joint_outcomes', () => {
|
||
expect(sql007).toContain('CREATE TABLE IF NOT EXISTS ship_joint_outcomes');
|
||
});
|
||
|
||
test('creates global_calibration', () => {
|
||
expect(sql007).toContain('CREATE TABLE IF NOT EXISTS global_calibration');
|
||
});
|
||
});
|