og-graph 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # og-graph
2
+
3
+ > Descripción del paquete
4
+
5
+ ## Instalación
6
+
7
+ ```bash
8
+ npm install og-graph
9
+ ```
10
+
11
+ ## Uso
12
+
13
+ ```js
14
+ const { hello } = require('og-graph');
15
+
16
+ hello('world'); // "Hello, world!"
17
+ ```
18
+
19
+ ## API
20
+
21
+ ### `hello(name)`
22
+
23
+ Descripción de la función.
24
+
25
+ ## License
26
+
27
+ MIT
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "og-graph",
3
+ "version": "0.0.1",
4
+ "description": "",
5
+ "main": "./src/index.js",
6
+ "scripts": {
7
+ "test": "node test/index.test.js",
8
+ "prepublishOnly": "npm test"
9
+ },
10
+ "keywords": [],
11
+ "author": "",
12
+ "license": "MIT",
13
+ "files": [
14
+ "src/"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ }
19
+ }
@@ -0,0 +1,289 @@
1
+ // activitySignals.cjs
2
+ function clamp(n, a, b) {
3
+ return Math.max(a, Math.min(b, n));
4
+ }
5
+ function mean(arr) {
6
+ if (!arr || !arr.length) return 0;
7
+ let s = 0;
8
+ for (const x of arr) s += x;
9
+ return s / arr.length;
10
+ }
11
+
12
+ function smoothSeries(series, radius = 2) {
13
+ const n = series.length;
14
+ const out = new Array(n).fill(0);
15
+ for (let i = 0; i < n; i++) {
16
+ const a = Math.max(0, i - radius);
17
+ const b = Math.min(n - 1, i + radius);
18
+ let s = 0, c = 0;
19
+ for (let k = a; k <= b; k++) { s += series[k]; c++; }
20
+ out[i] = c ? s / c : series[i];
21
+ }
22
+ return out;
23
+ }
24
+
25
+ function resampleGraphPoints(graphPoints, maxMinute = 90) {
26
+ const pts = [...(graphPoints || [])].sort((a, b) => a.minute - b.minute);
27
+ const series = new Array(maxMinute + 1).fill(0);
28
+ let j = 0, last = 0;
29
+ for (let m = 0; m <= maxMinute; m++) {
30
+ while (j < pts.length && pts[j].minute <= m) {
31
+ last = Number(pts[j].value) || 0;
32
+ j++;
33
+ }
34
+ series[m] = last;
35
+ }
36
+ return series;
37
+ }
38
+
39
+ function totalPressureFromSmooth(smooth) {
40
+ let home = 0, away = 0;
41
+ for (const v of smooth) {
42
+ if (v > 0) home += v;
43
+ else away += -v;
44
+ }
45
+ return { home, away, total: home + away };
46
+ }
47
+
48
+ function detectPhases(smooth, T = 10) {
49
+ const phases = [];
50
+ let cur = null;
51
+
52
+ function teamFrom(v) {
53
+ if (v > T) return "home";
54
+ if (v < -T) return "away";
55
+ return "neutral";
56
+ }
57
+
58
+ for (let m = 0; m < smooth.length; m++) {
59
+ const t = teamFrom(smooth[m] || 0);
60
+ if (!cur) cur = { team: t, start: m, end: m };
61
+ else if (t === cur.team) cur.end = m;
62
+ else {
63
+ phases.push(cur);
64
+ cur = { team: t, start: m, end: m };
65
+ }
66
+ }
67
+ if (cur) phases.push(cur);
68
+
69
+ return phases.map(p => {
70
+ const slice = smooth.slice(p.start, p.end + 1);
71
+ const avg = mean(slice);
72
+ const peakAbs = slice.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
73
+ return {
74
+ team: p.team,
75
+ start: p.start,
76
+ end: p.end,
77
+ duration: p.end - p.start + 1,
78
+ mean: avg,
79
+ intensity: Math.abs(avg),
80
+ peakAbs
81
+ };
82
+ });
83
+ }
84
+
85
+ function buildScoreTimeline(goals, maxMinute) {
86
+ const g = [...(goals || [])].sort((a, b) => a.minute - b.minute);
87
+ const out = Array.from({ length: maxMinute + 1 }, () => ({ home: 0, away: 0 }));
88
+ let h = 0, a = 0, j = 0;
89
+ for (let m = 0; m <= maxMinute; m++) {
90
+ while (j < g.length && g[j].minute <= m) {
91
+ if (g[j].team === "home") h++;
92
+ else if (g[j].team === "away") a++;
93
+ j++;
94
+ }
95
+ out[m] = { home: h, away: a };
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function stateFromScore(sc) {
101
+ if (sc.home > sc.away) return "home_leading";
102
+ if (sc.away > sc.home) return "away_leading";
103
+ return "draw";
104
+ }
105
+
106
+ function gameStateImpact(smooth, scoreTimeline) {
107
+ const buckets = {
108
+ home_leading: { home: 0, away: 0, minutes: 0 },
109
+ away_leading: { home: 0, away: 0, minutes: 0 },
110
+ draw: { home: 0, away: 0, minutes: 0 }
111
+ };
112
+
113
+ const maxM = Math.min(smooth.length - 1, scoreTimeline.length - 1);
114
+ for (let m = 1; m <= maxM; m++) {
115
+ const state = stateFromScore(scoreTimeline[m]);
116
+ const v = smooth[m] || 0;
117
+ if (v > 0) buckets[state].home += v;
118
+ else buckets[state].away += -v;
119
+ buckets[state].minutes += 1;
120
+ }
121
+
122
+ const out = {};
123
+ for (const [k, b] of Object.entries(buckets)) {
124
+ out[k] = {
125
+ minutes: b.minutes,
126
+ homePerMin: b.minutes ? b.home / b.minutes : 0,
127
+ awayPerMin: b.minutes ? b.away / b.minutes : 0
128
+ };
129
+ }
130
+ return out;
131
+ }
132
+
133
+ function leadFragilityMinutes(smooth, scoreTimeline, T = 10) {
134
+ const maxMinute = Math.min(smooth.length - 1, scoreTimeline.length - 1);
135
+ let homeFrag = 0, awayFrag = 0;
136
+
137
+ for (let m = 1; m <= maxMinute; m++) {
138
+ const sc = scoreTimeline[m];
139
+ const v = smooth[m] || 0;
140
+ if (sc.home > sc.away && v < -T) homeFrag++;
141
+ if (sc.away > sc.home && v > T) awayFrag++;
142
+ }
143
+ return { homeLeadingButDominatedMinutes: homeFrag, awayLeadingButDominatedMinutes: awayFrag };
144
+ }
145
+
146
+ function volatilityIndex(smooth, volT = 12) {
147
+ let flips = 0;
148
+ let last = 0; // -1 away, +1 home, 0 neutral
149
+ for (let m = 1; m < smooth.length; m++) {
150
+ const v = smooth[m] || 0;
151
+ const side = v > volT ? 1 : v < -volT ? -1 : 0;
152
+ if (side !== 0 && last !== 0 && side !== last) flips++;
153
+ if (side !== 0) last = side;
154
+ }
155
+ return { flipsStrong: flips };
156
+ }
157
+
158
+ // Presión en ventana para alertas live (simple)
159
+ function windowPressure(smooth, m, W = 10) {
160
+ const a = Math.max(0, m - W);
161
+ let home = 0, away = 0;
162
+ for (let k = a; k < m; k++) {
163
+ const v = smooth[k] || 0;
164
+ if (v > 0) home += v;
165
+ else away += -v;
166
+ }
167
+ return { home, away };
168
+ }
169
+
170
+ function buildAlerts(smooth, scoreTimeline, opts = {}) {
171
+ const W = opts.W ?? 10;
172
+ const T = opts.T ?? 10;
173
+ const pressureMin = opts.pressureMin ?? 80;
174
+ const alerts = [];
175
+
176
+ for (let m = 1; m < smooth.length; m++) {
177
+ const sc = scoreTimeline[m];
178
+ const v = smooth[m] || 0;
179
+
180
+ // lead_fragile
181
+ if (sc.home !== sc.away) {
182
+ const leadTeam = sc.home > sc.away ? "home" : "away";
183
+ const dominatedBy =
184
+ (leadTeam === "home" && v < -T) ? "away" :
185
+ (leadTeam === "away" && v > T) ? "home" :
186
+ null;
187
+
188
+ if (dominatedBy) {
189
+ const wp = windowPressure(smooth, m, W);
190
+ const domVal = dominatedBy === "home" ? wp.home : wp.away;
191
+ if (domVal >= pressureMin) {
192
+ alerts.push({ minute: m, type: "lead_fragile", leadTeam, dominatedBy, windowPressure: wp });
193
+ }
194
+ }
195
+ }
196
+
197
+ // late_goal_pressure (70+)
198
+ if (m >= 70) {
199
+ const wp = windowPressure(smooth, m, W);
200
+ const likely =
201
+ wp.home >= pressureMin && wp.home > wp.away ? "home" :
202
+ wp.away >= pressureMin && wp.away > wp.home ? "away" :
203
+ null;
204
+ if (likely) alerts.push({ minute: m, type: "late_goal_pressure", teamLikely: likely, windowPressure: wp });
205
+ }
206
+ }
207
+
208
+ // compacta: evita spam igual minuto-tipo
209
+ const seen = new Set();
210
+ return alerts.filter(a => {
211
+ const k = `${a.minute}-${a.type}-${a.teamLikely || a.leadTeam}`;
212
+ if (seen.has(k)) return false;
213
+ seen.add(k);
214
+ return true;
215
+ });
216
+ }
217
+
218
+ // --- Clasificación de goles (mejorada) ---
219
+ function buildPressureAtMinute(smooth, m, W) {
220
+ const a = Math.max(0, m - W);
221
+ let home = 0, away = 0;
222
+ for (let k = a; k < m; k++) {
223
+ const v = smooth[k] ?? 0;
224
+ if (v > 0) home += v;
225
+ else away += -v;
226
+ }
227
+ return { home, away };
228
+ }
229
+
230
+ function meanWindow(smooth, from, toExclusive) {
231
+ const a = clamp(from, 0, smooth.length);
232
+ const b = clamp(toExclusive, 0, smooth.length);
233
+ if (b <= a) return 0;
234
+ let s = 0, c = 0;
235
+ for (let i = a; i < b; i++) { s += smooth[i] ?? 0; c++; }
236
+ return c ? s / c : 0;
237
+ }
238
+
239
+ function classifyGoal(goal, smooth, opts = {}) {
240
+ const { minute, team } = goal;
241
+ const Wshort = opts.Wshort ?? 3;
242
+ const Wlong = opts.Wlong ?? 10;
243
+
244
+ const m = clamp(Math.floor(minute), 0, smooth.length - 1);
245
+
246
+ const ps = buildPressureAtMinute(smooth, m, Wshort);
247
+ const pl = buildPressureAtMinute(smooth, m, Wlong);
248
+
249
+ const ratioShort = (team === "home" ? ps.home : ps.away) / (ps.home + ps.away + 1e-9);
250
+ const ratioLong = (team === "home" ? pl.home : pl.away) / (pl.home + pl.away + 1e-9);
251
+
252
+ const label =
253
+ (ratioShort >= 0.60 && ratioLong >= 0.60) ? "flow" :
254
+ (ratioShort <= 0.40 && ratioLong <= 0.40) ? "against_flow" :
255
+ "mixed";
256
+
257
+ const preTrend = meanWindow(smooth, m - Wlong, m);
258
+ const postTrend = meanWindow(smooth, m + 1, m + 1 + Wlong);
259
+ const swing = postTrend - preTrend;
260
+
261
+ const localShock = Math.abs((smooth[m] ?? 0) - (smooth[m - 1] ?? 0));
262
+ const absLevel = Math.abs(smooth[m] ?? 0);
263
+
264
+ const brokeScript =
265
+ (Math.sign(preTrend) !== Math.sign(postTrend) && Math.abs(swing) >= (opts.swingT ?? 10)) ||
266
+ (localShock >= (opts.shockT ?? 20)) ||
267
+ (absLevel >= (opts.levelT ?? 35));
268
+
269
+ return {
270
+ minute, team,
271
+ ratioShort, ratioLong, label,
272
+ preTrend, postTrend, swing,
273
+ brokeScript,
274
+ signals: { localShock, absLevel }
275
+ };
276
+ }
277
+
278
+ module.exports = {
279
+ resampleGraphPoints,
280
+ smoothSeries,
281
+ totalPressureFromSmooth,
282
+ detectPhases,
283
+ buildScoreTimeline,
284
+ gameStateImpact,
285
+ leadFragilityMinutes,
286
+ volatilityIndex,
287
+ buildAlerts,
288
+ classifyGoal
289
+ };
@@ -0,0 +1,145 @@
1
+ function clamp01(x) {
2
+ if (!Number.isFinite(x)) return 0;
3
+ return x < 0 ? 0 : x > 1 ? 1 : x;
4
+ }
5
+ function abs(x) { return Math.abs(Number(x) || 0); }
6
+ function r2(x) { return Math.round((Number(x) || 0) * 100) / 100; }
7
+
8
+ function scoreDiff(finalScore) {
9
+ const h = finalScore?.home ?? 0;
10
+ const a = finalScore?.away ?? 0;
11
+ return h - a;
12
+ }
13
+
14
+ function winner(finalScore) {
15
+ const d = scoreDiff(finalScore);
16
+ return d > 0 ? "home" : d < 0 ? "away" : "draw";
17
+ }
18
+
19
+ function buildMatchAnomaly(report, opts = {}) {
20
+ // Pesos recomendados (los que ya vienes usando bien)
21
+ const W = {
22
+ fairness: opts.wFairness ?? 0.25,
23
+ fragility: opts.wFragility ?? 0.35,
24
+ mismatch: opts.wMismatch ?? 0.15,
25
+ volatility: opts.wVolatility ?? 0.25
26
+ };
27
+
28
+ const TH = {
29
+ fairnessStrong: opts.fairnessStrong ?? 0.15,
30
+ fragStrong: opts.fragStrong ?? 0.45,
31
+ flipsStrong: opts.flipsStrong ?? 5,
32
+ xMismatch1: opts.xMismatch1 ?? 0.75,
33
+ xMismatch2: opts.xMismatch2 ?? 1.25,
34
+ lateMin: opts.lateMin ?? 75
35
+ };
36
+
37
+ const finalScore = report?.finalScore || { home: 0, away: 0 };
38
+ const win = winner(finalScore);
39
+
40
+ const fairness = Number(report?.fairness ?? 0);
41
+ const fairnessN = clamp01(abs(fairness) / 0.35);
42
+
43
+ const leadMin =
44
+ (report?.gameStateImpact?.home_leading?.minutes || 0) +
45
+ (report?.gameStateImpact?.away_leading?.minutes || 0);
46
+
47
+ const dominatedWhileLeading =
48
+ (report?.leadFragility?.homeLeadingButDominatedMinutes || 0) +
49
+ (report?.leadFragility?.awayLeadingButDominatedMinutes || 0);
50
+
51
+ const fragility = leadMin ? dominatedWhileLeading / leadMin : 0;
52
+ const fragilityN = clamp01(fragility);
53
+
54
+ const xh = Number(report?.xScore?.home ?? 0);
55
+ const xa = Number(report?.xScore?.away ?? 0);
56
+ const xDiff = xh - xa;
57
+ const realDiff = scoreDiff(finalScore);
58
+ const mismatch = abs(xDiff - realDiff);
59
+ const mismatchN = clamp01((mismatch - TH.xMismatch1) / (TH.xMismatch2 - TH.xMismatch1));
60
+
61
+ const flips = Number(report?.volatility?.flipsStrong ?? 0);
62
+ const volatilityN = clamp01(flips / 10);
63
+
64
+ const MAI01 =
65
+ W.fairness * fairnessN +
66
+ W.fragility * fragilityN +
67
+ W.mismatch * mismatchN +
68
+ W.volatility * volatilityN;
69
+
70
+ const flags = [];
71
+
72
+ const dominantSide = fairness > 0 ? "home" : fairness < 0 ? "away" : "draw";
73
+ if (abs(fairness) >= TH.fairnessStrong && win !== "draw" && dominantSide !== "draw" && win !== dominantSide) {
74
+ flags.push("DOMINANCE_NO_RESULT");
75
+ }
76
+
77
+ if (leadMin >= 20 && fragility >= TH.fragStrong) flags.push("FRAGILE_LEAD");
78
+ if (mismatch >= TH.xMismatch1) flags.push("SCORE_MISMATCH");
79
+ if (flips >= TH.flipsStrong) flags.push("HIGH_VOLATILITY");
80
+
81
+ if (win !== "draw" && Array.isArray(report?.goals) && report.goals.length) {
82
+ const goals = report.goals.slice().sort((a, b) => a.minute - b.minute);
83
+ // aproximación: gol decisivo = último gol del ganador
84
+ let decisive = null;
85
+ for (const g of goals) if (g.team === win) decisive = g;
86
+
87
+ if (decisive && decisive.minute >= TH.lateMin) {
88
+ if (decisive.label === "mixed" || decisive.label === "against_flow") {
89
+ flags.push("LATE_DECISIVE_AGAINST_FLOW");
90
+ }
91
+ }
92
+ }
93
+
94
+ // Bonus por flags
95
+ let bonus = 0;
96
+ if (flags.includes("FRAGILE_LEAD")) bonus += 8;
97
+ if (flags.includes("HIGH_VOLATILITY")) bonus += 6;
98
+ if (flags.includes("LATE_DECISIVE_AGAINST_FLOW")) bonus += 10;
99
+ if (flags.includes("DOMINANCE_NO_RESULT")) bonus += 10;
100
+
101
+ const MAI = Math.min(100, Math.max(0, Math.round(100 * MAI01) + bonus));
102
+
103
+ let label = "Normal";
104
+ if (MAI >= 70) label = "Partido roto";
105
+ else if (MAI >= 50) label = "Resultado engañoso";
106
+ else if (MAI >= 30) label = "Inestable";
107
+
108
+ const reasons = [];
109
+ if (abs(fairness) >= TH.fairnessStrong) reasons.push(`Dominio ${fairness > 0 ? "local" : "visitante"} significativo (fairness ${r2(fairness)})`);
110
+ if (leadMin && fragility >= TH.fragStrong) reasons.push(`Ventaja frágil (${Math.round(fragility * 100)}% del tiempo liderando bajo dominio rival)`);
111
+ if (mismatch >= TH.xMismatch1) reasons.push(`Marcador vs xScore no cuadra (mismatch ${r2(mismatch)})`);
112
+ if (flips >= TH.flipsStrong) reasons.push(`Alta volatilidad (flipsStrong ${flips})`);
113
+ if (flags.includes("LATE_DECISIVE_AGAINST_FLOW")) reasons.push(`Gol decisivo tardío fuera de tendencia`);
114
+
115
+ const summaryParts = [];
116
+ if (flags.includes("DOMINANCE_NO_RESULT")) summaryParts.push("dominó pero no ganó");
117
+ if (flags.includes("FRAGILE_LEAD")) summaryParts.push("ventaja insostenible");
118
+ if (flags.includes("SCORE_MISMATCH")) summaryParts.push("marcador engañoso");
119
+ if (flags.includes("HIGH_VOLATILITY")) summaryParts.push("partido caótico");
120
+ if (flags.includes("LATE_DECISIVE_AGAINST_FLOW")) summaryParts.push("gol tardío antinatural");
121
+
122
+ const summary = summaryParts.length ? summaryParts.join(" · ") : "partido dentro de lo esperado";
123
+
124
+ return {
125
+ MAI,
126
+ label,
127
+ flags,
128
+ reasons,
129
+ summary,
130
+ components: {
131
+ fairness: r2(fairness),
132
+ fairnessN: r2(fairnessN),
133
+ fragility: r2(fragility),
134
+ fragilityN: r2(fragilityN),
135
+ mismatch: r2(mismatch),
136
+ mismatchN: r2(mismatchN),
137
+ flips,
138
+ volatilityN: r2(volatilityN),
139
+ winner: win,
140
+ dominantSide
141
+ }
142
+ };
143
+ }
144
+
145
+ module.exports = { buildMatchAnomaly };
package/src/index.js ADDED
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+
4
+ const { postMatchReport } = require("./postMatchSignals.cjs");
5
+ const { buildMatchAnomaly } = require("./buildMatchAnomaly.cjs");
6
+ const { buildMatchNarrative } = require("./matchNarrative.cjs");
7
+
8
+
9
+ async function graphActivity(data = {}) {
10
+
11
+ const { id, incidents, graph } = data;
12
+
13
+ const matchId = Number(id);
14
+ if (!matchId) {
15
+ return { ok: false, error: { code: "missing_id", message: "Match id inválido" } }
16
+ }
17
+
18
+ if (incidents?.ok === false) {
19
+ return { ok: false, error: { code: "empty_data", message: "missing incidents" } }
20
+ }
21
+
22
+ if (graph?.ok === false) {
23
+ return { ok: false, error: { code: "empty_data", message: "missing graph" } }
24
+ }
25
+
26
+ const goles = [
27
+ ...incidents.incidents
28
+ .filter(i => i.incidentType === "goal")
29
+ .map(i => ({ minute: i.time, team: "home" })),
30
+
31
+ ...incidents.incidents
32
+ .filter(i => i.incidentType === "goal")
33
+ .map(i => ({ minute: i.time, team: "away" })),
34
+ ];
35
+
36
+ const input = {
37
+ graphPoints: graph.graphPoints,
38
+ goals: goles
39
+ };
40
+
41
+ const report = postMatchReport(input, {
42
+ maxMinute: 90,
43
+ W: 7,
44
+ T: 10,
45
+ smoothRadius: 2,
46
+ volT: 12
47
+ });
48
+
49
+
50
+ const anomaly = buildMatchAnomaly(report);
51
+ const narrative = buildMatchNarrative({ report, anomaly });
52
+
53
+ const MAI = paintMAI(narrative.sentiment.MAI);
54
+ const Pos = paintPos(narrative.keyFacts.dominanceShare);
55
+
56
+ const computed = {
57
+ mai: MAI,
58
+ pos: Pos
59
+ }
60
+
61
+ const response = { report, anomaly, narrative, computed };
62
+
63
+ return response;
64
+
65
+ }
66
+
67
+
68
+ function paintPos({ home, away }) {
69
+ const total = home + away;
70
+
71
+ if (!total) {
72
+ return { home: 0, away: 0 };
73
+ }
74
+
75
+ const homePct = Math.round((home / total) * 100);
76
+ const awayPct = 100 - homePct; // garantiza 100% exacto
77
+
78
+ return {
79
+ home: homePct,
80
+ away: awayPct
81
+ };
82
+ }
83
+
84
+ function paintMAI(MAI) {
85
+ if (MAI >= 70) return { icon: "🔴", label: "Partido roto" };
86
+ if (MAI >= 50) return { icon: "🟠", label: "Resultado engañoso" };
87
+ if (MAI >= 30) return { icon: "🟡", label: "Inestable" };
88
+ return { icon: "🟢", label: "Normal" };
89
+ }
90
+
91
+ module.exports = { graphActivity }
92
+
@@ -0,0 +1,117 @@
1
+ // matchNarrative.cjs
2
+ function pct(x) { return `${Math.round((Number(x) || 0) * 100)}%`; }
3
+ function r2(x) { return Math.round((Number(x) || 0) * 100) / 100; }
4
+ function safe(n, d = 0) { return Number.isFinite(n) ? n : d; }
5
+
6
+ function winner(finalScore) {
7
+ const h = finalScore?.home ?? 0;
8
+ const a = finalScore?.away ?? 0;
9
+ return h > a ? "home" : a > h ? "away" : "draw";
10
+ }
11
+ function sideName(side) { return side === "home" ? "Local" : side === "away" ? "Visitante" : "Empate"; }
12
+
13
+ function computeEfficiencyFactor(report) {
14
+ const eh = safe(report?.efficiency?.home, 0);
15
+ const ea = safe(report?.efficiency?.away, 0);
16
+ if (eh <= 0 || ea <= 0) return { factor: 0, side: "none" };
17
+ if (eh >= ea) return { factor: eh / ea, side: "home" };
18
+ return { factor: ea / eh, side: "away" };
19
+ }
20
+
21
+ function pickArchetype({ report, anomaly }, opts = {}) {
22
+ const fs = report?.finalScore || {};
23
+ const win = winner(fs);
24
+ const dominantSide = anomaly?.components?.dominantSide || (report?.fairness > 0 ? "home" : "away");
25
+ const eff = computeEfficiencyFactor(report);
26
+ const effExtremeT = opts.effExtremeT ?? 2.5;
27
+
28
+ if ((report?.volatility?.flipsStrong || 0) >= (opts.flipsChaos ?? 6)) {
29
+ return { archetype: "Partido caótico", feeling: "Fuera de guion / impredecible" };
30
+ }
31
+
32
+ if (anomaly?.flags?.includes("DOMINANCE_NO_RESULT")) {
33
+ if (win !== "draw" && eff.factor >= effExtremeT && eff.side === win) {
34
+ return { archetype: "Victoria por eficiencia extrema", feeling: "Ganó contra el guion (clínico/azar)" };
35
+ }
36
+ return { archetype: "Marcador mentiroso", feeling: "Ganó el que no dominó" };
37
+ }
38
+
39
+ if (anomaly?.flags?.includes("FRAGILE_LEAD") && win !== "draw") {
40
+ return { archetype: "Ventaja frágil", feeling: "Partido tenso / se sostuvo con pinzas" };
41
+ }
42
+
43
+ if (anomaly?.label === "Resultado engañoso") return { archetype: "Resultado engañoso", feeling: "Lectura peligrosa" };
44
+ if (anomaly?.label === "Inestable") return { archetype: "Partido inestable", feeling: "Tenso" };
45
+ return { archetype: "Normal", feeling: "Controlado" };
46
+ }
47
+
48
+ function buildMatchNarrative({ report, anomaly }, opts = {}) {
49
+ const fs = report.finalScore;
50
+ const win = winner(fs);
51
+ const dominantSide = anomaly?.components?.dominantSide || (report.fairness > 0 ? "home" : "away");
52
+
53
+ const domShare = report?.dominanceShare || {};
54
+ const domHome = safe(domShare.home, 0);
55
+ const domAway = safe(domShare.away, 0);
56
+
57
+ const fairness = safe(report?.fairness, 0);
58
+ const xh = safe(report?.xScore?.home, 0);
59
+ const xa = safe(report?.xScore?.away, 0);
60
+ const mismatch = safe(anomaly?.components?.mismatch, 0);
61
+
62
+ const eff = computeEfficiencyFactor(report);
63
+ const effText = eff.factor > 0 ? `${sideName(eff.side)} fue ~${r2(eff.factor)}× más eficiente` : "Eficiencia no concluyente";
64
+
65
+ const gs = report?.gameStateImpact || {};
66
+ const homeLead = gs.home_leading || { minutes: 0, homePerMin: 0, awayPerMin: 0 };
67
+ const awayLead = gs.away_leading || { minutes: 0, homePerMin: 0, awayPerMin: 0 };
68
+
69
+ const frag = report?.leadFragility || {};
70
+ const fragHome = frag.homeLeadingButDominatedMinutes || 0;
71
+ const fragAway = frag.awayLeadingButDominatedMinutes || 0;
72
+
73
+ const { archetype, feeling } = pickArchetype({ report, anomaly }, opts);
74
+
75
+ const bullets = [];
76
+ bullets.push(`Dominio: ${sideName(dominantSide)} (${pct(dominantSide === "home" ? domHome : domAway)} de presión, fairness ${r2(fairness)})`);
77
+ bullets.push(`xScore: ${r2(xh)}–${r2(xa)} (mismatch ${r2(mismatch)})`);
78
+ bullets.push(effText);
79
+
80
+ if (homeLead.minutes) bullets.push(`Cuando Local iba ganando (${homeLead.minutes}m): presión/min Local ${r2(homeLead.homePerMin)} vs Visitante ${r2(homeLead.awayPerMin)}`);
81
+ if (awayLead.minutes) bullets.push(`Cuando Visitante iba ganando (${awayLead.minutes}m): presión/min Local ${r2(awayLead.homePerMin)} vs Visitante ${r2(awayLead.awayPerMin)}`);
82
+
83
+ if (fragHome || fragAway) bullets.push(`Fragilidad: Local lideró dominado ${fragHome}m · Visitante lideró dominado ${fragAway}m`);
84
+
85
+ const flips = report?.volatility?.flipsStrong || 0;
86
+ bullets.push(`Volatilidad: flipsStrong ${flips}`);
87
+
88
+ const short =
89
+ anomaly.label === "Resultado engañoso"
90
+ ? `Marcador mentiroso: dominó ${sideName(dominantSide)} pero ganó ${sideName(win)}`
91
+ : `${anomaly.label}: ${anomaly.summary}`;
92
+
93
+ const narrative = [
94
+ `${sideName(win)} gana ${fs.home}-${fs.away}, pero el partido favoreció a ${sideName(dominantSide)}.`,
95
+ `Se esperaba algo cercano a ${r2(xh)}–${r2(xa)} (xScore).`,
96
+ `${effText}.`
97
+ ].join(" ");
98
+
99
+ return {
100
+ sentiment: { label: anomaly.label, feeling, archetype, MAI: anomaly.MAI },
101
+ keyFacts: {
102
+ winner: win,
103
+ dominantSide,
104
+ finalScore: fs,
105
+ dominanceShare: { home: domHome, away: domAway },
106
+ fairness,
107
+ xScore: { home: xh, away: xa },
108
+ mismatch,
109
+ efficiencyFactor: eff
110
+ },
111
+ bullets,
112
+ short,
113
+ narrative
114
+ };
115
+ }
116
+
117
+ module.exports = { buildMatchNarrative };
@@ -0,0 +1,89 @@
1
+ // postMatchSignals.cjs
2
+ const {
3
+ resampleGraphPoints,
4
+ smoothSeries,
5
+ totalPressureFromSmooth,
6
+ detectPhases,
7
+ buildScoreTimeline,
8
+ gameStateImpact,
9
+ leadFragilityMinutes,
10
+ volatilityIndex,
11
+ buildAlerts,
12
+ classifyGoal
13
+ } = require("./activitySignals.cjs");
14
+
15
+ function estimateK(totalPressure, totalGoals) {
16
+ const g = Math.max(1, totalGoals);
17
+ return totalPressure / g;
18
+ }
19
+
20
+ function postMatchReport(data, options = {}) {
21
+ const maxMinute = options.maxMinute ?? 90;
22
+ const W = options.W ?? 7;
23
+ const T = options.T ?? 10;
24
+ const smoothRadius = options.smoothRadius ?? 2;
25
+
26
+ const graphPoints = data?.graphPoints || [];
27
+ const goals = data?.goals || [];
28
+
29
+ const rawSeries = resampleGraphPoints(graphPoints, maxMinute);
30
+ const smooth = smoothSeries(rawSeries, smoothRadius);
31
+
32
+ const totals = totalPressureFromSmooth(smooth);
33
+
34
+ const fairness = totals.total > 0 ? (totals.home - totals.away) / totals.total : 0;
35
+
36
+ const scoreTimeline = buildScoreTimeline(goals, maxMinute);
37
+ const finalScore = scoreTimeline[maxMinute];
38
+ const totalGoals = (finalScore.home || 0) + (finalScore.away || 0);
39
+
40
+ const K = options.K ?? estimateK(totals.total, totalGoals);
41
+ const xScore = { home: totals.home / (K || 1), away: totals.away / (K || 1), K };
42
+
43
+ const efficiency = {
44
+ home: (finalScore.home || 0) / (totals.home + 1e-9),
45
+ away: (finalScore.away || 0) / (totals.away + 1e-9)
46
+ };
47
+
48
+ const phases = detectPhases(smooth, T);
49
+ const gs = gameStateImpact(smooth, scoreTimeline);
50
+ const frag = leadFragilityMinutes(smooth, scoreTimeline, T);
51
+ const vol = volatilityIndex(smooth, options.volT ?? 12);
52
+
53
+ const goalsAnalysis = goals.map(g => classifyGoal(g, smooth, {
54
+ Wshort: options.Wshort ?? 3,
55
+ Wlong: options.Wlong ?? 10,
56
+ swingT: options.swingT ?? 10,
57
+ shockT: options.shockT ?? 20,
58
+ levelT: options.levelT ?? 35
59
+ }));
60
+
61
+ const alerts = buildAlerts(smooth, scoreTimeline, {
62
+ W: options.alertW ?? 10,
63
+ T,
64
+ pressureMin: options.pressureMin ?? 80
65
+ });
66
+
67
+ return {
68
+ finalScore,
69
+ dominanceShare: totals.total ? { home: totals.home / totals.total, away: totals.away / totals.total } : { home: 0.5, away: 0.5 },
70
+ fairness,
71
+ xScore: { home: Number(xScore.home.toFixed(2)), away: Number(xScore.away.toFixed(2)), K: Number(xScore.K.toFixed(2)) },
72
+ efficiency: { home: Number(efficiency.home.toFixed(6)), away: Number(efficiency.away.toFixed(6)) },
73
+ leadFragility: frag,
74
+ gameStateImpact: gs,
75
+ turningPoints: [], // lo puedes implementar luego (opcional)
76
+ volatility: vol,
77
+ phases,
78
+ goals: goalsAnalysis,
79
+ alertsLiveStyle: alerts,
80
+ summary: {
81
+ totalPressure: { home: totals.home, away: totals.away },
82
+ dominance: totals.home >= totals.away ? "home" : "away",
83
+ finalScore
84
+ },
85
+ debug: { TUsed: T, WUsed: W, smoothRadius }
86
+ };
87
+ }
88
+
89
+ module.exports = { postMatchReport };