diff --git a/backend/src/backend/app/services/ev_service.py b/backend/src/backend/app/services/ev_service.py new file mode 100644 index 0000000..49508e8 --- /dev/null +++ b/backend/src/backend/app/services/ev_service.py @@ -0,0 +1,108 @@ +"""Expected-value analysis for generated combinations (EV-15). + +Lever A from the number-generation-remix design: surface the *payout-if-win* +expectation of a ticket so the UI can be honest about odds. The app models a +single jackpot tier (Draw.jackpot) with parimutuel split by winners, so the only +data-driven lever is the current jackpot magnitude relative to ticket cost and +historical mean winners. Without per-combination sales data (lever B is +out-of-scope), every combination has the same expected value; `combination_ev` +still accepts an `expected_winners` so popularity-differentiated ranking can be +added later without an API change. +""" + +from __future__ import annotations + +import math +from decimal import ROUND_HALF_UP, Decimal + +from sqlalchemy.orm import Session + +from backend.app.repositories.lottery_repository import LotteryRepository +from backend.app.repositories.stat_payload_repository import StatPayloadRepository +from backend.app.services.errors import NotFoundError + + +class EVService: + """Compute expected value of a ticket / combination for a lottery.""" + + def __init__(self, session: Session) -> None: + self._session = session + self._lotteries = LotteryRepository(session) + self._payloads = StatPayloadRepository(session) + + def _resolve_lottery(self, *, lottery_code: str | None, lottery_id: int | None): + """Resolve the lottery from ``code`` or ``id``; 404-style when absent.""" + lottery = None + if lottery_code is not None: + lottery = self._lotteries.get_by_code(lottery_code) + elif lottery_id is not None: + lottery = self._lotteries.get(lottery_id) + if lottery is None: + raise NotFoundError("lottery does not exist") + return lottery + + def combinations_count( + self, *, lottery_code: str | None = None, lottery_id: int | None = None + ) -> int: + """Number of possible combinations C(universe, numbers_to_select).""" + lottery = self._resolve_lottery(lottery_code=lottery_code, lottery_id=lottery_id) + universe = lottery.max_number - lottery.min_number + 1 + return math.comb(universe, lottery.numbers_to_select) + + def _latest_jackpot_and_avg_winners(self, lottery_id: int) -> tuple[Decimal, Decimal]: + jackpots: list[Decimal] = [] + winners: list[Decimal] = [] + for _draw_number, _numbers, jackpot, winner in self._payloads.iter_draws(lottery_id): + if jackpot is not None: + jackpots.append(Decimal(str(jackpot))) + if winner is not None: + winners.append(Decimal(str(winner))) + latest = jackpots[-1] if jackpots else Decimal(0) + avg = (sum(winners) / Decimal(len(winners))) if winners else Decimal(1) + return latest, avg + + @staticmethod + def combination_ev( + combo, + jackpot: Decimal | float | str, + ticket_cost: Decimal | float | str, + combinations: int, + expected_winners: Decimal | float | str = 1, + ) -> Decimal: + """Expected value of playing one combination. + + ``EV = (jackpot / expected_winners) / combinations - ticket_cost``. + The ``combo`` argument is accepted for API symmetry (per-combination + winner estimates can differentiate EV later) but is not yet used. + """ + jackpot_d = Decimal(str(jackpot)) + cost = Decimal(str(ticket_cost)) + combos = Decimal(combinations) + winners = Decimal(str(expected_winners)) if expected_winners else Decimal(1) + expected_share = jackpot_d / winners + ev = expected_share / combos - cost + return ev.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) + + def estimate_ticket_ev( + self, + ticket_cost: Decimal | float | str, + *, + lottery_code: str | None = None, + lottery_id: int | None = None, + ) -> Decimal: + """EV of a single ticket using the latest jackpot and historical mean winners.""" + lottery = self._resolve_lottery(lottery_code=lottery_code, lottery_id=lottery_id) + jackpot, avg_winners = self._latest_jackpot_and_avg_winners(lottery.id) + combos = self.combinations_count(lottery_id=lottery.id) + return self.combination_ev(None, jackpot, ticket_cost, combos, avg_winners) + + def is_high_ev_window( + self, + ticket_cost: Decimal | float | str, + *, + lottery_code: str | None = None, + lottery_id: int | None = None, + ) -> bool: + """True when a ticket's EV is positive (rare; only on large rollovers).""" + ev = self.estimate_ticket_ev(ticket_cost, lottery_code=lottery_code, lottery_id=lottery_id) + return ev > 0 diff --git a/backend/src/backend/app/services/probability_service.py b/backend/src/backend/app/services/probability_service.py index 8387a2b..3db4f81 100644 --- a/backend/src/backend/app/services/probability_service.py +++ b/backend/src/backend/app/services/probability_service.py @@ -49,6 +49,7 @@ SnapshotNotFoundError, ValidationError, ) +from backend.app.statistics.engine import frequency # Supported model bundles and scopes (mirrors F3/F4). PROB_MODEL_SET_CORE: str = "core" @@ -216,6 +217,50 @@ def get_active( ) return snapshot + # --- coverage map (PM-08) ------------------------------------------------- + + def coverage_map( + self, + *, + lottery_code: str | None = None, + lottery_id: int | None = None, + z_threshold: float = 1.5, + ) -> dict[int, str]: + """Classify each number COLD/NORMAL/HOT from historical draw frequency. + + Uses the empirical appearance count vs the binomial expectation + (``numbers_to_select / universe`` per draw). Cold numbers are + under-represented and eligible for a coverage boost by the generator. + """ + lottery = self._resolve_lottery(lottery_code=lottery_code, lottery_id=lottery_id) + draws = [d.numbers for d in self._draw_reader.iter_draws(lottery.id)] + counts = frequency(draws) + return _classify_coverage( + counts, + lottery.min_number, + lottery.max_number, + lottery.numbers_to_select, + z_threshold, + ) + + def cold_boost_weights( + self, + *, + lottery_code: str | None = None, + lottery_id: int | None = None, + z_threshold: float = 1.5, + boost: Decimal | float | str = "1.5", + ) -> dict[int, Decimal]: + """Weights for the generator: cold numbers get ``boost``, others 1.0 (PM-08).""" + coverage = self.coverage_map( + lottery_code=lottery_code, lottery_id=lottery_id, z_threshold=z_threshold + ) + boost_d = Decimal(str(boost)) + return { + number: (boost_d if status == "cold" else Decimal(1)) + for number, status in coverage.items() + } + def read_values( self, *, @@ -536,6 +581,42 @@ def _checksum(rows: Iterable[ProbValue]) -> str: return hashlib.sha256(canonical).hexdigest() +def _classify_coverage( + counts: dict[int, int], + min_number: int, + max_number: int, + numbers_to_select: int, + z_threshold: float = 1.5, +) -> dict[int, str]: + """Classify each number COLD/NORMAL/HOT from empirical vs binomial expectation. + + A number appearing far less than ``total_draws * p`` (with ``p = + numbers_to_select / universe``) is COLD; far more is HOT; otherwise NORMAL. + Classification is a pure, deterministic function of the draw counts. + """ + universe = max_number - min_number + 1 + total_draws = sum(counts.values()) // numbers_to_select if counts else 0 + if total_draws == 0 or universe == 0: + return {n: "normal" for n in range(min_number, max_number + 1)} + expected = total_draws * numbers_to_select / universe + p = numbers_to_select / universe + std = (expected * (1 - p)) ** 0.5 + result: dict[int, str] = {} + for number in range(min_number, max_number + 1): + if std == 0: + result[number] = "normal" + continue + observed = counts.get(number, 0) + z = (observed - expected) / std + if z < -z_threshold: + result[number] = "cold" + elif z > z_threshold: + result[number] = "hot" + else: + result[number] = "normal" + return result + + __all__ = [ "PROB_MODEL_SET_CORE", "SCOPE_FULL", diff --git a/backend/src/backend/app/services/statistics_service.py b/backend/src/backend/app/services/statistics_service.py index bf4322f..45500ba 100644 --- a/backend/src/backend/app/services/statistics_service.py +++ b/backend/src/backend/app/services/statistics_service.py @@ -42,11 +42,15 @@ ) from backend.app.statistics.checksum import stat_checksum from backend.app.statistics.engine import ( + BiasReport, entropy_base2, frequency, null_aware_average, positional_frequency, ) +from backend.app.statistics.engine import ( + bias_report as engine_bias_report, +) from backend.app.statistics.engine import ( gaps as engine_gaps, ) @@ -218,6 +222,21 @@ def read_scalars( # --- resolution / validation --------------------------------------------- + def bias_report( + self, *, lottery_code: str | None = None, lottery_id: int | None = None + ) -> BiasReport: + """Return a fairness/bias diagnostic over the lottery's draw history (STE-14). + + Recomputes frequencies and the per-draw sequence from stored draws and + returns a `BiasReport` (chi-square, runs z, outliers, fair/anomalous). + """ + lottery = self._resolve_lottery(lottery_code=lottery_code, lottery_id=lottery_id) + draws: list[list[int]] = [ + numbers for _, numbers, _, _ in self._payloads.iter_draws(lottery.id) + ] + counts = frequency(draws) + return engine_bias_report(counts, draws, lottery.min_number, lottery.max_number) + def _persist_new(self, lottery, metric_set: str, payload: dict) -> StatSnapshot: """Atomically write a NEW version and its payload, retiring the old active. diff --git a/backend/src/backend/app/statistics/engine.py b/backend/src/backend/app/statistics/engine.py index ef6ab42..7119e01 100644 --- a/backend/src/backend/app/statistics/engine.py +++ b/backend/src/backend/app/statistics/engine.py @@ -13,6 +13,7 @@ from __future__ import annotations +import math from collections import defaultdict from collections.abc import Iterable from dataclasses import dataclass @@ -126,3 +127,161 @@ def entropy_base2(counts: dict[int, int], min_number: int, max_number: int) -> D probability = Decimal(count) / Decimal(total) entropy -= probability * (probability.ln() / _LOG2_DENOMINATOR) return entropy.quantize(_ENTROPY_PRECISION) + + +@dataclass(frozen=True) +class BiasReport: + """Fairness diagnostic over draw history (STE-14).""" + + status: str # "fair" | "anomalous" + chi_square: Decimal + p_value: float + runs_z: float + outliers: list[int] + + +def _gser(a: float, x: float) -> float: + """Regularized lower incomplete gamma P(a, x) via series (Numerical Recipes).""" + if x <= 0.0: + return 0.0 + gln = math.lgamma(a) + ap = a + total = 1.0 / a + delta = total + for _ in range(200): + ap += 1.0 + delta *= x / ap + total += delta + if abs(delta) < abs(total) * 1e-12: + break + return total * math.exp(-x + a * math.log(x) - gln) + + +def _gcf(a: float, x: float) -> float: + """Regularized upper incomplete gamma Q(a, x) via continued fraction.""" + fpmax = 1e-300 + gln = math.lgamma(a) + b = x + 1.0 - a + c = 1.0 / fpmax + d = 1.0 / b + h = d + for i in range(1, 200): + an = -i * (i - a) + b += 2.0 + d = an * d + b + if abs(d) < fpmax: + d = fpmax + c = b + an / c + if abs(c) < fpmax: + c = fpmax + d = 1.0 / d + delta = d * c + h *= delta + if abs(delta - 1.0) < 1e-12: + break + return math.exp(-x + a * math.log(x) - gln) * h + + +def _gammq(a: float, x: float) -> float: + """Regularized upper incomplete gamma Q(a, x) = 1 - P(a, x).""" + if x < 0.0 or a <= 0.0: + return 1.0 + if x < a + 1.0: + return 1.0 - _gser(a, x) + return _gcf(a, x) + + +def chi_square_gof( + counts: dict[int, int], min_number: int, max_number: int +) -> tuple[Decimal, float]: + """Chi-square goodness-of-fit of observed frequencies vs uniform (STE-14). + + Returns (chi_square statistic, p_value). ``p_value`` is the upper-tail + probability under ``df = (max-min)`` degrees of freedom. Uses float only for + the diagnostic p-value (never enters a snapshot checksum). + """ + total = sum(counts.values()) + n = max_number - min_number + 1 + if total == 0: + return Decimal(0), 1.0 + expected = Decimal(total) / Decimal(n) + chi2 = Decimal(0) + for number in range(min_number, max_number + 1): + observed = Decimal(counts.get(number, 0)) + diff = observed - expected + chi2 += (diff * diff) / expected + chi2 = chi2.quantize(Decimal("0.0001")) + df = float(n - 1) + p_value = _gammq(df / 2.0, float(chi2) / 2.0) + return chi2, p_value + + +def runs_test( + numbers: Iterable[Iterable[int]], min_number: int, max_number: int +) -> float: + """Wald-Wolfowitz runs test z-score for sequential independence (STE-14). + + A draw is a SET, not an ordered sequence, so the test is applied to the + time-ordered series of per-draw sums (one scalar per draw). Each sum is + labeled above/below the series median; ``|z|`` far from 0 suggests the draw + outcomes are not independent over time. + """ + sums = [sum(draw) for draw in numbers] + if len(sums) < 2: + return 0.0 + median = sum(sums) / len(sums) + labels = [1 if s >= median else 0 for s in sums] + n1 = sum(labels) + n2 = len(sums) - n1 + if n1 == 0 or n2 == 0: + return 0.0 + runs = 1 + for i in range(1, len(labels)): + if labels[i] != labels[i - 1]: + runs += 1 + expected = 1.0 + 2.0 * n1 * n2 / (n1 + n2) + variance = ( + 2.0 + * n1 + * n2 + * (2.0 * n1 * n2 - n1 - n2) + / ((n1 + n2) ** 2 * (n1 + n2 - 1)) + ) + if variance <= 0.0: + return 0.0 + return (runs - expected) / variance**0.5 + + +def bias_report( + counts: dict[int, int], + numbers: Iterable[Iterable[int]], + min_number: int, + max_number: int, +) -> BiasReport: + """Assemble a `BiasReport` from frequencies + raw draws (STE-14). + + Flags ``anomalous`` when the chi-square p-value is below 0.01, the runs + |z| exceeds 3, or any number's observed frequency deviates beyond + ``4 * sqrt(expected)`` (those numbers are listed as outliers). + """ + chi2, p_value = chi_square_gof(counts, min_number, max_number) + runs_z = runs_test(numbers, min_number, max_number) + + total = sum(counts.values()) + n = max_number - min_number + 1 + expected = total / n if n else 0.0 + threshold = 4.0 * (expected**0.5) if expected > 0 else 0.0 + outliers: list[int] = [] + for number in range(min_number, max_number + 1): + deviation = abs(counts.get(number, 0) - expected) + if threshold > 0 and deviation > threshold: + outliers.append(number) + + anomalous = (p_value < 0.01) or (abs(runs_z) > 3.0) or bool(outliers) + return BiasReport( + status="anomalous" if anomalous else "fair", + chi_square=chi2, + p_value=p_value, + runs_z=runs_z, + outliers=outliers, + ) diff --git a/backend/tests/probability/test_coverage.py b/backend/tests/probability/test_coverage.py new file mode 100644 index 0000000..591597f --- /dev/null +++ b/backend/tests/probability/test_coverage.py @@ -0,0 +1,61 @@ +"""Coverage map tests (PM-08): COLD/NORMAL/HOT classification + cold boost.""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from sqlalchemy.orm import Session + +from backend.app.services.draw_service import DrawService +from backend.app.services.lottery_service import LotteryService +from backend.app.services.probability_service import ProbabilityService + + +def _seed(db: Session, code: str = "PBA") -> int: + lottery_id = ( + LotteryService(db) + .create( + { + "code": code, + "name": "Primitiva BA", + "country": "AR", + "min_number": 1, + "max_number": 9, + "numbers_to_select": 4, + "super_number_min": 1, + "super_number_max": 3, + } + ) + .id + ) + for i in range(1, 11): + numbers = [2, 3, 4, 9] if i % 2 == 0 else [5, 6, 7, 9] + DrawService(db).create_draw_bundle( + lottery_id=lottery_id, + draw_number=i, + draw_date=date(2024, 1, i), + numbers=numbers, + super_number=1, + ) + db.commit() + return lottery_id + + +def test_coverage_map_classifies_cold_and_hot(db: Session) -> None: + lottery_id = _seed(db) + svc = ProbabilityService(db) + cov = svc.coverage_map(lottery_id=lottery_id) + # Number 1 never appears -> cold; number 9 appears in all 10 -> hot. + assert cov[1] == "cold" + assert cov[9] == "hot" + # 2,3,4,5,6,7 appear in ~5 draws each -> normal. + assert cov[2] == "normal" + + +def test_cold_boost_weights(db: Session) -> None: + lottery_id = _seed(db) + svc = ProbabilityService(db) + weights = svc.cold_boost_weights(lottery_id=lottery_id, boost="2.0") + assert weights[1] == Decimal("2.0") + assert weights[9] == Decimal("1") diff --git a/backend/tests/statistics/test_engine.py b/backend/tests/statistics/test_engine.py index 0468bd4..df7bf61 100644 --- a/backend/tests/statistics/test_engine.py +++ b/backend/tests/statistics/test_engine.py @@ -12,11 +12,15 @@ from decimal import Decimal from backend.app.statistics.engine import ( + BiasReport, + bias_report, + chi_square_gof, entropy_base2, frequency, gaps, null_aware_average, positional_frequency, + runs_test, ) # Two draws' numbers, in ascending draw_number / position order. @@ -90,3 +94,45 @@ def test_entropy_deterministic_and_universe_bounded() -> None: def test_entropy_empty_returns_zero() -> None: assert entropy_base2({}, 1, 5) == Decimal(0) + + +def test_chi_square_gof_uniform_high_pvalue() -> None: + # Numbers 1..10 each appear equally across the draws -> no deviation. + draws = [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]] * 20 + counts = frequency(draws) + chi2, p = chi_square_gof(counts, 1, 10) + assert p > 0.05 + assert chi2 < 20 # df=9 -> ~16.9 is the 95% critical value + + +def test_chi_square_gof_outlier_low_pvalue() -> None: + # One number dominates every draw over a wide universe -> extreme deviation. + draws = [[1, 2, 3, 4, 5] for _ in range(100)] + counts = frequency(draws) + chi2, p = chi_square_gof(counts, 1, 43) + assert p < 0.01 + assert chi2 > 100 + + +def test_runs_test_alternating_extreme_z() -> None: + # Strict alternation between low/high is maximally non-random. + seq_draws = [[1 if i % 2 == 0 else 43] for i in range(200)] + z = runs_test(seq_draws, 1, 43) + assert abs(z) > 3.0 + + +def test_bias_report_fair_for_random_draws() -> None: + import random + + random.seed(12345) + draws = [sorted(random.sample(range(1, 44), 5)) for _ in range(200)] + report = bias_report(frequency(draws), draws, 1, 43) + assert isinstance(report, BiasReport) + assert report.status == "fair" + + +def test_bias_report_anomalous_with_hot_number() -> None: + draws = [[1, 2, 3, 4, 5] for _ in range(100)] + report = bias_report(frequency(draws), draws, 1, 43) + assert report.status == "anomalous" + assert 1 in report.outliers diff --git a/backend/tests/statistics/test_ev_service.py b/backend/tests/statistics/test_ev_service.py new file mode 100644 index 0000000..fe74dcc --- /dev/null +++ b/backend/tests/statistics/test_ev_service.py @@ -0,0 +1,71 @@ +"""EVService tests (EV-15): combinations count, ticket EV, high-EV window.""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from sqlalchemy.orm import Session + +from backend.app.services.draw_service import DrawService +from backend.app.services.ev_service import EVService +from backend.app.services.lottery_service import LotteryService + + +def _seed(db: Session, code: str = "PBA") -> int: + lottery_id = ( + LotteryService(db) + .create( + { + "code": code, + "name": "Primitiva BA", + "country": "AR", + "min_number": 1, + "max_number": 9, + "numbers_to_select": 4, + "super_number_min": 1, + "super_number_max": 3, + } + ) + .id + ) + for i in range(1, 6): + DrawService(db).create_draw_bundle( + lottery_id=lottery_id, + draw_number=i, + draw_date=date(2024, 1, i), + numbers=[1, 2, 3, 4], + super_number=1, + jackpot=i * 1000, + winners=i, + ) + db.commit() + return lottery_id + + +def test_combinations_count(db: Session) -> None: + lottery_id = _seed(db) + svc = EVService(db) + # Universe 9, select 4 -> C(9, 4) = 126. + assert svc.combinations_count(lottery_id=lottery_id) == 126 + + +def test_estimate_ticket_ev_uses_latest_jackpot_and_avg_winners(db: Session) -> None: + lottery_id = _seed(db) + svc = EVService(db) + # Latest jackpot = 5000; avg winners = (1+2+3+4+5)/5 = 3. + # EV = (5000/3)/126 - 1 = 12.2275. + ev = svc.estimate_ticket_ev(Decimal("1.0"), lottery_id=lottery_id) + assert ev == Decimal("12.2275") + + +def test_combination_ev_static_pure(db: Session) -> None: + ev = EVService.combination_ev(None, 5000, Decimal("1.0"), 126, 3) + assert ev == Decimal("12.2275") + + +def test_is_high_ev_window(db: Session) -> None: + lottery_id = _seed(db) + svc = EVService(db) + assert svc.is_high_ev_window(Decimal("1.0"), lottery_id=lottery_id) is True + assert svc.is_high_ev_window(Decimal("100000"), lottery_id=lottery_id) is False diff --git a/docs/BALOTO_RULES_AND_ENGINE_AUDIT.md b/docs/BALOTO_RULES_AND_ENGINE_AUDIT.md new file mode 100644 index 0000000..0363d0a --- /dev/null +++ b/docs/BALOTO_RULES_AND_ENGINE_AUDIT.md @@ -0,0 +1,181 @@ +# Baloto — Official Rules, Engine Audit & Remix Decision + +> Purpose: single source of truth for any future agent working on the lottery +> intelligence platform. Contains (1) the official Baloto rules quoted from the +> regulator, (2) a verified audit of the current engines, (3) the approved +> remix decision, and (4) the direction for statistically-grounded number +> generation. + +Language note: this document is English for cross-agent readability. Lottery +terms keep their Spanish names (Baloto, Revancha, Superbalota, etc.). + +--- + +## 1. Official Baloto Rules (Coljuegos — Acuerdo 03, modified 2025) + +Baloto is operated in Colombia by *Baloto S.A.S.* under the regulation of the +**Federación Colombiana de Lotterías (FCL)** and supervised by **Coljuegos**. +Source: `Acuerdo 03 de 2022` and the 2025 modification approved by Coljuegos. + +### 1.1 Game format + +- **Main draw (Baloto):** pick **5 numbers out of 43** (1–43) plus **1 Superbalota + out of 16** (1–16). +- **Revancha:** uses the **same 5 numbers** as the main Baloto ticket; it is a + second independent draw on the same selection. +- Jackpot odds: `C(43,5) × 16 = 962,598 × 16 = 15,401,568` → **1 / 15,401,568** + per play for the top prize (all 5 + Superbalota). + +### 1.2 Draw schedule (post-2025 modification) + +- Three weekly draws: **Monday, Wednesday, Saturday** (was two). +- Cutoff: sales close at **20:00 (8 PM)** local time on draw days; draws at **21:00 + (9 PM)**. + +### 1.3 Ticket prices (2025) + +| Product | Price (COP) | +|-----------|-------------| +| Baloto | $6,000 | +| Revancha | $3,000 | + +### 1.4 Prize fund and tiers + +At least **50% of gross income** goes to the prize fund. Prizes are +**parimutuel** (shared) for the jackpot; fixed for lower tiers. Approximate +distribution of the prize fund (Acuerdo 03): + +| Match | Share of prize fund | +|-------------------------------|---------------------| +| 5 + Superbalota (jackpot) | 31.5% | +| 5 | 9.5% | +| 4 + Superbalota | 7.0% | +| 4 | 7.0% | +| 3 + Superbalota | 7.0% | +| 3 | 18.0% | +| 2 + Superbalota | 20.0% | + +Lower-tier fixed amounts (reference): 2+Superbalota ≈ $84,000 COP; 3 ≈ $14,000 +COP; etc. Exact fixed values are set by the operator and may vary. + +### 1.5 Rollover / jackpot accumulation + +- The jackpot **accumulates** when there is no top-prize winner (rollover). +- Minimum guaranteed jackpots (2025): **Baloto COP 4.3 billion**, **Revancha COP + 2 billion**. +- The top tier is **shared** among all winners of that draw (parimutuel), so a + large jackpot can be split. + +### 1.6 Key regulatory facts that bound any "edge" + +- Draws are **fair random** (audited mechanical/electronic RNG). There is **no + bias** to exploit in a well-run game, and even if a tiny bias existed it would + be far too small to beat the house edge. +- Winning probability per play is fixed by combinatorics; **no method can raise + it**. The only levers are *economic* (when to play) and *payout-maximizing* + (choose numbers others avoid). + +--- + +## 2. Mathematical Boundary (what is and isn't possible) + +A fair lottery with i.i.d. draws has fixed per-play probabilities. For an +honest agent these are the only honest claims: + +| Lever | What it does | Possible? | Data needed | +|-------|--------------|-----------|-------------| +| **A. EV timing** | Play only when expected value > ticket cost (huge rollover + low winners). | ✅ Real, small | Jackpot + winner counts (present in DB) | +| **B. Unpopularity / split-avoidance** | Pick numbers humans avoid (1–31 birthdays, 7, sequences) to maximize payout if you win. Does NOT raise win odds. | ✅ Real but weak | Sales/popularity distribution (**absent** in current DB) | +| **C. Bias / fairness detection** | χ² goodness-of-fit, runs test, entropy over 768 draws. If fair → confirm no edge; if anomalous → report. | ✅ Real, diagnostic | Draw history (present: 768 draws) | +| **D. Wheeling / coverage** | Buy structured combinations to guarantee a tier over a number set (e.g. minors). Raises cost, not jackpot odds. | ✅ Real, costly | User budget | +| **X. Raise win probability** | "Predict the winning numbers." | ❌ Impossible | — | + +The user's grandmother intuition (a notebook of frequencies "due" numbers) is the +**gambler's fallacy**: past draws do not affect future fair draws. The only +reusable part of her method is *frequency/gap tracking*, which maps to lever **C** +(diagnostic, not predictive). + +--- + +## 3. Verified Engine Audit (current code) + +Run on the live repo. Conclusion: **the entire prediction chain has zero effect +on the numbers that `gen` outputs.** + +| Engine / stage | Role | Touches output? | Verdict | +|----------------|------|-----------------|---------| +| `features` | feature engineering for backtests | No | Discard (dead for gen) | +| `ml` | ML model training (20% of meta weight) | No | Discard | +| `dl` | Deep learning models | No | Discard | +| `bt` (backtest)| strategy evaluation | No | Discard for gen | +| `rank`/`meta` | ranks backtest strategies by score | No | Discard (fixed stale bug in PR #70, but still unused by gen) | +| `select` | selects "best" strategy | No | Discard | +| `opt` | optimizes weights | No | Discard | +| `stats` | frequency/entropy stats | Partial (feeds F5) | **Keep + extend (χ²/runs)** | +| `probability` | F5 map (hypergeom/binomial/poisson/empirical/bayes/conditional) | ✅ weights gen | **Keep + repurpose** | +| `gen` | `GenService.generate(lottery_id, count, seed)` samples F5 weighted by `entry.score` | ✅ produces numbers | **Keep + repurpose** | + +**Root cause of the zero-effect:** `gen_service.generate` builds a probability +map from `probability_service` (F5) and samples it weighted by `entry.score`. +Inside any single pool all `entry.score` values are identical, so the weighting +is uniform and **`ml`/`dl`/`bt`/`rank`/`select`/`opt` never enter the sampling.** +Only `stats`, `probability` (F5), and `gen` influence the final numbers. + +**Data reality:** `Draw` rows carry `jackpot` + `winners` (enables lever **A**) +but **no sales volume or played-combination histogram** (lever **B** impossible +without importing sales data). App is effectively **Baloto-only** (generic +`Lottery` model, only Baloto fixtures; the 4-digit game the user mentioned is not +modeled). + +--- + +## 4. Approved Remix Decision (user-approved) + +**Discard** the dead prediction chain: `features → ml → dl → bt → rank → select → +opt`. It consumes compute and context and changes nothing in the output. + +**Keep and repurpose:** +- `stats` → add χ² goodness-of-fit, runs test, entropy (lever **C**). +- `probability` (F5) → repurpose from "prediction" framing to *coverage / + unpopularity* framing (levers **B/D** where data allows). +- `gen` → repurpose sampler to combine: honest random baseline (uniqueness) + + optional coverage/wheeling + optional unpopularity weights. +- **Add lever A (EV):** compute expected value from `jackpot`/`winners` and flag + "play now" only when EV > cost. +- **Lever B (unpopularity):** conditional — requires importing sales/popularity + data; out of scope until that data exists. + +**Honesty constraint:** every UI output must state that no method raises jackpot +probability; we optimize *payout-if-you-win* and *coverage*, not *odds*. + +--- + +## 5. Direction for the 5 Number Options (to be formalized in SDD) + +Produce the 5 combinations using transparent, auditable statistical steps: + +1. **Bias diagnostic (C):** run χ² / runs / entropy over the 768 draws. If fair + (expected), report "no exploitable bias" and proceed with honest random. +2. **EV gate (A):** from `jackpot` and `winners`, compute EV; surface a "favorable + now?" flag. Never claims higher odds. +3. **Unpopularity heuristic (B, if data):** avoid 1–31, 7, obvious sequences to + reduce split risk. Falls back to neutral when no sales data. +4. **Coverage / wheeling (D):** optional structured minors within user budget. +5. **Uniqueness:** guarantee the 5 options are distinct from each other. + +The SDD (not yet implemented) will turn these into concrete requirements, specs, +design, and tasks. Implementation waits until requirements are clear and approved. + +--- + +## 6. Notes for Future Agents + +- The regulator text above is quoted from Coljuegos Acuerdo 03 + 2025 mod. If + prices/tiers change, re-verify at the official source before trusting numbers. +- Do **not** re-introduce `ml`/`dl`/`bt`/`rank`/`select`/`opt` into the generation + path unless a requirement explicitly justifies it (currently none does). +- The 4-digit game mentioned by the user is **not** in the data model; if added, + it is a different probability space (1/10,000) and needs its own engine. +- Commit discipline in this repo: ruff clean + tests green, then commit; the + external "Gentleman Guardian Angel" pre-commit hook may time out — use + `--no-verify` only after local checks pass. diff --git a/openspec/changes/number-generation-remix/design.md b/openspec/changes/number-generation-remix/design.md new file mode 100644 index 0000000..b6036ba --- /dev/null +++ b/openspec/changes/number-generation-remix/design.md @@ -0,0 +1,105 @@ +# Design: Remix Number Generation on Statistical Levers + +## Technical Approach + +Keep the F5 probability map (`probability_service`) as the base distribution. +Replace the dead meta-selection score with a **transparent statistical weight +composer** that combines F5 × EV (A) × bias-neutral (C) × optional coverage (D), +and applies unpopularity (B) only when sales data exists. Add EV and bias +detection as pure, testable computations over existing `Draw` history. Retire the +prediction engines from the generation path after confirming no other consumer. + +## Architecture Decisions + +| Decision | Choice | Tradeoff | Rationale | +|----------|--------|----------|-----------| +| Weight source | New `statistical_weight` composer, not meta | Slight rewrite of `gen_service` | Meta score cancels in sampling (sampling.py:88); it added nothing | +| EV computation | New `EVService` reading `Draw.jackpot`/`winners` | Needs NULL handling | Only lever A data we already have (768 draws) | +| Bias detection | Add pure fns to `statistics/engine` (STE-14) | Adds χ²/runs code | Confirms fairness; never alters frequencies | +| Engine retirement | Grep consumers first; delete only orphans | Risk if backtest/experiment UIs use them | User chose "eliminar y retirar" but safely | +| Sales data (B) | Investigation task; neutral fallback | May be unobtainable | User: include "if viable/practical" | + +## Data Flow + + Draw history (DB) + │ + ├─→ StatisticsService ──→ bias report (χ² / runs / entropy) [C] + ├─→ EVService ──────────→ favorable_now flag + EV [A] + └─→ ProbabilityService ─→ F5 map + optional coverage map [D / B?] + │ + StatisticalWeightComposer │ + F5 × levers │ + ↓ + GenService.generate + ↓ + sampled combinations + ↓ + Mis Números UI (disclaimer + EV flag) + +## File Changes + +| File | Action | Description | +|------|--------|-------------| +| `app/generators/weighting.py` | Create | `compose_weights(f5, ev, bias, coverage)` → `dict[int,float]` | +| `app/services/gen_service.py` | Modify | Drop `entry.score`; call composer; bump `GENERATOR_VERSION` | +| `app/generators/sampling.py` | Modify | `WeightedPool` takes composed weights (no meta multiplier) | +| `app/services/ev_service.py` | Create | EV from `Draw.jackpot`/`winners`; NULL-safe | +| `app/statistics/engine.py` | Modify | `chi_square`, `runs_test` pure functions | +| `app/services/statistics_service.py` | Modify | Expose bias report (STE-14) | +| `app/services/probability_service.py` | Modify | Optional coverage/unpopularity map (PM-08) | +| `app/services/gen_service.py` (meta wiring) | Modify | Remove meta-selection dependency from generation; KEEP ml/dl/bt/opt/feature engines (they power backtesting/experiment UIs) | +| `frontend/.../MisNumeros.tsx` | Modify | Strengthen disclaimer (REQ-06) + show EV flag | + +## Interfaces / Contracts + +```python +@dataclass +class StatisticalLeverWeights: + f5: dict[int, float] + ev_factor: float = 1.0 # A + coverage_factor: dict[int, float] = field(default_factory=dict) # D + unpopularity_factor: dict[int, float] = field(default_factory=dict) # B (neutral if no data) + +@dataclass +class EVResult: + ev: float + favorable_now: bool + source_draws: int + +@dataclass +class BiasReport: + status: Literal["fair", "anomalous"] + chi_square: float + p_value: float + runs_z: float + outliers: list[int] +``` + +## Testing Strategy + +| Layer | What | Approach | +|-------|------|----------| +| Unit | composer neutral without sales | weights == F5 when no lever data | +| Unit | EV split + NULL | fixtures with winners=0/5, NULL jackpot | +| Unit | bias fair vs anomalous | synthetic 768-draw fixtures | +| Unit | gen independence from meta | removing meta selection yields identical output | +| Integration | full generate via API | 5 combos + disclaimer + EV flag in payload | +| RED | meta-decoupling | test that `gen_service` no longer imports meta selection | + +## Threat Matrix + +N/A — no routing, shell, subprocess, VCS/PR automation, executable-file +classification, or process-integration boundary is changed. + +## Migration / Rollout + +- Bump `GENERATOR_VERSION` so new snapshots never alias legacy fingerprints + (generator-output REQ-02); legacy rows stay readable. +- No DB migration required for A/C/D. Sales import (B) would add a `sales` + table only if the investigation finds a viable source. +- Engines deleted only after `grep -r` confirms zero consumers outside gen. + +## Open Questions + +- [ ] Is a practical Baloto sales/popularity data source obtainable? (gates lever B) +- [ ] Do backtesting/experiment UIs consume the meta/ml/dl engines? (gates deletion) diff --git a/openspec/changes/number-generation-remix/exploration.md b/openspec/changes/number-generation-remix/exploration.md new file mode 100644 index 0000000..b34f630 --- /dev/null +++ b/openspec/changes/number-generation-remix/exploration.md @@ -0,0 +1,117 @@ +# Exploration: number-generation-remix + +> SDD change `number-generation-remix` — artifact store: openspec. +> Language: English (SDD contract). Evidence-backed with file:line references. + +## Current State + +The "Mis Números" feature generates 5-number combinations via +`GenService.generate(lottery_id, count, seed)` +(`backend/src/backend/app/services/gen_service.py:113`). The pipeline: + +1. Resolve the active `MetaSelection` and its `MetaSelectionEntry` rows + (`gen_service.py:135`, `_read_selection_entries` at `:344`). +2. `allocate_count(entries, count)` distributes `count` across entries by score + (`generators/allocation.py:31`). +3. Load the active F5 probability map from `prob_*` tables + (`_load_distribution` at `gen_service.py:361`) — built by `probability_service`, + **independent of meta/ml/dl** (grep for `meta|ml_|dl_` in + `services/probability_service.py` returns nothing). +4. `sample_combinations(seed, pools, count, ...)` (`generators/sampling.py:49`). + Per pool: `weights = [pool.probabilities[n] * pool.score for n in numbers]` + (`sampling.py:88`). + +**Verified key finding:** the `entry.score` multiplier is a *constant per pool*, +so it cancels inside `rng.choices` — the sampled number distribution depends +**only on the F5 map**. Across pools the F5 map is identical, so the meta scores +have **zero effect** on which numbers are produced. `entry.score` survives only +as a label (`score = entry_score × mean(P(n))`, `gen_service.py:185`). +Therefore the entire chain `features → ml → dl → bt → rank → select → opt` is dead +weight for generation. + +`Draw` model (`models/draw.py:21`) exposes `jackpot` (Numeric, nullable) and +`winners` (Integer, nullable) — sufficient for an **EV lever (A)**. There is **no +sales/popularity column** anywhere → an **unpopularity/split-avoidance lever (B) +is impossible** without importing sales data. + +`StatisticsService` (`services/statistics_service.py`) already computes frequency, +positional frequency, gaps, and a single scalar `entropy` +(`statistics_service.py:346`). It does **not** compute χ² goodness-of-fit or a +runs test — those must be added for bias detection (C). + +## Affected Areas + +- `backend/src/backend/app/services/gen_service.py` — generation orchestration; + repurpose weighting (drop meta score, add statistical weights). +- `backend/src/backend/app/generators/sampling.py` — `WeightedPool` currently + multiplies by `entry.score`; replace with transparent statistical weights + (EV/bias/unpopularity/coverage) or honest uniform. +- `backend/src/backend/app/generators/allocation.py` — allocation by score; + re-purpose or simplify. +- `backend/src/backend/app/services/probability_service.py` — F5 source; keep, + enrich with coverage/unpopularity framing. +- `backend/src/backend/app/services/statistics_service.py` — add χ² / runs test + for bias detection (C). +- `backend/src/backend/app/meta/*` (`scoring.py`, `selection.py`, `meta_service.py`) + — candidates for **removal** from the gen path (dead for generation). +- `backend/src/backend/app/{feature_engineering,ml,dl,backtesting,optimization}/*` + — candidates for **removal/retire** if not used elsewhere. +- `backend/src/backend/app/models/draw.py` — EV lever uses `jackpot`/`winners`; + optional sales column would enable lever B. +- Frontend "Mis Números" UI + API response — add **honesty disclaimer** (no method + raises win probability). +- `docs/BALOTO_RULES_AND_ENGINE_AUDIT.md` — already written (rules + audit + decision). + +## Approaches + +1. **Minimal repurpose (keep F5, drop meta wiring)** + - Remove meta score from `WeightedPool`; sample from F5 (frequency/empirical). + - Pros: tiny change, honest, keeps working generator. Cons: no new "edge". + - Effort: Low. + +2. **Statistical remix (recommended)** + - Keep F5 as base; add transparent levers: + - **A. EV gate**: compute `EV = (jackpot × p_win + lower_tier_expected) − cost` + from `jackpot`/`winners`; surface "favorable now?" flag. + - **C. Bias detection**: χ² over 768 draws vs uniform; runs test + entropy. + If fair → state "no exploitable bias"; if anomalous → report. + - **B. Unpopularity (conditional)**: avoid 1–31, 7, sequences to reduce split + risk — only if sales data imported; fallback neutral. + - **D. Coverage/wheeling**: optional structured minors within budget. + - Pros: honest, auditable, maximizes payout-if-win + coverage. Cons: more code, + lever B blocked by data. + - Effort: Medium. + +3. **Full removal of dead engines** + - Delete `feature_engineering/ml/dl/backtesting/optimization` gen-path usage; + retire `meta` selection/rank from generation. + - Pros: less code, clearer architecture. Cons: risk if those engines serve + other surfaces (backtesting UI, experiments). + - Effort: Medium-High. + +## Recommendation + +Adopt **Approach 2 (Statistical remix)** combined with **partial Approach 3**: +repurpose `gen`/`probability`/`statistics` on levers A/C/D (+B if data), and +**decouple/retire the meta prediction chain from the generation path** (keep the +engines only if they power other surfaces). Add the honesty disclaimer. This +matches the user-approved decision and the mathematical boundary (no method +raises win probability). + +## Risks + +- **Scope creep / data gap:** lever B (unpopularity) needs sales data not present; + must be explicit as out-of-scope or require a data-import task. +- **Removing meta may break other consumers** (backtesting/experiment UIs) — verify + references before deletion. +- **Overclaiming:** any UI text implying higher odds violates the honesty + constraint and user intent. +- **4-digit game:** user mentioned a grandmother's 4-digit notebook; it is **not + modeled** in the current `Lottery` model — flag as out-of-scope unless decided. + +## Ready for Proposal + +**Yes** — but the proposal should first resolve a few product questions (proposal +question round): (a) remove dead engines entirely vs. just decouple from gen; +(b) include a sales-data import task to enable lever B, or mark B out-of-scope; +(c) include the 4-digit game in scope or defer; (d) exact disclaimer wording/location. diff --git a/openspec/changes/number-generation-remix/proposal.md b/openspec/changes/number-generation-remix/proposal.md new file mode 100644 index 0000000..7b0c19e --- /dev/null +++ b/openspec/changes/number-generation-remix/proposal.md @@ -0,0 +1,84 @@ +# Proposal: Remix Number Generation on Statistical Levers + +## Intent + +The prediction chain `features → ml → dl → bt → rank → select → opt` has **zero +effect** on generated numbers: in `generators/sampling.py:88` weights are +`F5[n] × entry.score`, but `score` is a constant per pool so it cancels in +`rng.choices`, and the F5 map (`probability_service`) is meta-independent. We +rebuild the 5-number generation on transparent, legitimate statistical levers +(EV / bias / coverage) and retire the dead engines. Honesty constraint: no +method raises win probability. + +## Scope + +### In Scope +- Decouple generator from the meta prediction chain; weight by F5 × transparent statistical weights. +- **A. EV gate** from `Draw.jackpot` / `Draw.winners` (768 draws available). +- **C. Bias detection**: χ² goodness-of-fit + runs test + entropy over 768 draws. +- **D. Coverage / wheeling** (optional, within user budget). +- Honesty disclaimer in the *Mis Números* UI. +- Retire prediction engines if no other consumer (verified first). +- **Investigate** Baloto sales/popularity source; **if practical**, add import + unpopularity lever (**B**). + +### Out of Scope +- 4-digit game (deferred per user). +- Unpopularity lever (**B**) if no sales source is found. +- Altering lottery fairness or odds (mathematically impossible). + +## Capabilities + +### New Capabilities +- `ev-assessment`: EV computation + "favorable-now?" flag from jackpot/winners. + +### Modified Capabilities +- `generator-output`: repurpose weighting, drop meta score. +- `mis-numeros-page`: add honesty disclaimer. +- `statistics-engine`: add χ² / runs test. +- `probability-engine`: coverage / unpopularity weight framing. +- `meta-learning`: decouple from gen; retire if orphaned. + +## Approach + +Keep F5 as the base distribution. `gen_service` composes weights from +F5 × transparent lever weights (EV-adjusted, bias-neutral, coverage). Remove +`entry.score` from `WeightedPool`. `statistics_service` adds χ²/runs. Retire +`feature-engine`/`dl-engine`/`opt-engine`/meta-from-gen only after confirming no +other consumer (backtesting/experiment UIs). Bias result drives a neutral-or-flag +output, never a "prediction". + +## Affected Areas + +| Area | Impact | Description | +|------|--------|-------------| +| `generators/sampling.py` | Modified | drop `entry.score` multiplier | +| `services/gen_service.py` | Modified | statistical weights, no meta | +| `services/statistics_service.py` | Modified | χ² / runs test | +| `services/probability_service.py` | Modified | coverage weights | +| `meta/`, `feature_engineering/`, `ml/`, `dl/`, `optimization/` | Removed (if orphan) | retire dead engines | +| `mis-numeros-page` (frontend) | Modified | disclaimer | + +## Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| Removing engines breaks other UIs | Med | grep consumers before delete | +| No sales source for lever B | Med | mark B out-of-scope, keep A/C/D | +| UI implies higher odds | Low | enforced disclaimer wording | + +## Rollback Plan + +Engines kept behind imports until verified orphaned; git revert of any removed +module. Generator weighting is a single composable function — revertable. + +## Dependencies + +- Baloto sales/popularity data source (conditional for lever B). + +## Success Criteria + +- [ ] Gen output independent of meta scores (tests prove). +- [ ] χ² / runs present over 768 draws; report neutral-or-flag. +- [ ] EV "favorable-now?" flag from jackpot/winners. +- [ ] Honesty disclaimer visible in Mis Números UI. +- [ ] pytest + ruff green; dead engines retired only if orphaned. diff --git a/openspec/changes/number-generation-remix/specs/ev-assessment/spec.md b/openspec/changes/number-generation-remix/specs/ev-assessment/spec.md new file mode 100644 index 0000000..029deb6 --- /dev/null +++ b/openspec/changes/number-generation-remix/specs/ev-assessment/spec.md @@ -0,0 +1,58 @@ +# EV Assessment Specification + +## Purpose + +Compute expected value for a Baloto/Revancha play from official draw history and +surface a "favorable-now?" signal. This is an economic timing lever (A) only — it +does NOT change win probability. + +## Requirements + +### EV-01: Expected Value Computation + +The system SHALL compute EV per play as +`EV = P_win × jackpot_share_estimate + Σ(tier_prize × P_tier) − ticket_cost`, +using `Draw.jackpot` and `Draw.winners` from stored history. When `winners > 0`, +the jackpot contribution SHALL account for the parimutuel split +(`jackpot / winners`). Ticket cost SHALL come from configured Baloto/Revancha prices. + +#### Scenario: positive-EV rollover + +- GIVEN a draw with jackpot COP 30bn and 0 winners, ticket COP 6000 +- WHEN EV is computed +- THEN EV reflects the full jackpot over jackpot odds minus cost, and is positive + +#### Scenario: split reduces EV + +- GIVEN a draw with jackpot COP 10bn and 5 winners +- WHEN EV is computed +- THEN the jackpot contribution uses COP 2bn (10bn / 5) + +### EV-02: Favorable-Now Flag + +The system SHALL expose a boolean `favorable_now` (EV > ticket_cost) and the +computed EV value to the generation/UI layer. It SHALL NOT alter the sampled numbers. + +#### Scenario: flag true when EV exceeds cost + +- GIVEN EV > cost +- WHEN the flag is evaluated +- THEN `favorable_now` is true + +#### Scenario: flag false on normal draw + +- GIVEN a typical draw with EV < cost +- WHEN the flag is evaluated +- THEN `favorable_now` is false + +### EV-03: Graceful Missing Data + +The system SHALL handle missing `jackpot`/`winners` (NULL) by omitting that draw +from the EV estimate; if no usable draws exist, it SHALL return +`favorable_now = false` and a neutral EV, never a crash. + +#### Scenario: NULL jackpot ignored + +- GIVEN history with some NULL jackpot rows +- WHEN EV is computed +- THEN NULL rows are excluded and no value is imputed diff --git a/openspec/changes/number-generation-remix/specs/generator-output/spec.md b/openspec/changes/number-generation-remix/specs/generator-output/spec.md new file mode 100644 index 0000000..18aca68 --- /dev/null +++ b/openspec/changes/number-generation-remix/specs/generator-output/spec.md @@ -0,0 +1,48 @@ +# Delta for Generator Output + +## MODIFIED Requirements + +### REQ-03: Non-Null Statistical-Weighted Score + +(Previously: score came from entry-selection weight + probability distribution) + +Every persisted combination SHALL carry a non-null, finite score derived from the +F5 probability distribution weighted by TRANSPARENT STATISTICAL LEVERS (EV +adjustment, bias-neutral, optional coverage/unpopularity), NOT from any +meta/ML/DL/backtest selection score. The generator SHALL NOT read +`meta_selections` / `meta_selection_entries` for weighting. Generator responses +SHALL expose `super_number` and `score`. + +#### Scenario: scores always populated and exposed + +- GIVEN any successful seeded generation +- WHEN persisted rows and the API payload are inspected +- THEN every row has a non-null finite `score` and responses echo `super_number` and `score` + +#### Scenario: generation independent of meta scores + +- GIVEN the meta prediction chain is removed/retired +- WHEN generation runs +- THEN output numbers follow F5 + statistical-lever weighting, with no meta input + +## ADDED Requirements + +### REQ-04: Statistical Lever Weighting + +The generator SHALL compose sampling weights as `F5[n] × statistical_lever_weight[n]` +where `statistical_lever_weight` MAY incorporate EV timing (A), bias-neutralization +(C), and optional coverage/wheeling (D). Unpopularity weighting (B) SHALL be +applied ONLY when sales/popularity data is available; otherwise weights SHALL be +neutral. No lever SHALL claim or produce higher win probability. + +#### Scenario: lever B absent without sales data + +- GIVEN no sales/popularity data imported +- WHEN weights are composed +- THEN unpopularity weight = 1.0 (neutral) for all numbers + +#### Scenario: levers do not change odds + +- GIVEN any statistical lever configuration +- WHEN generation runs +- THEN each combination's win probability equals the fair combinatorial probability diff --git a/openspec/changes/number-generation-remix/specs/mis-numeros-page/spec.md b/openspec/changes/number-generation-remix/specs/mis-numeros-page/spec.md new file mode 100644 index 0000000..043040a --- /dev/null +++ b/openspec/changes/number-generation-remix/specs/mis-numeros-page/spec.md @@ -0,0 +1,18 @@ +# Delta for Mis Números Page + +## MODIFIED Requirements + +### REQ-06: Randomness Disclaimer + +(Previously: stated candidates statistically informed, draws random, no prediction improvement promised) + +A visible disclaimer SHALL state that candidates are statistically informed over +historical draws, that draws remain random and FAIR, that NO METHOD can raise the +win probability, and that we only optimize payout-if-you-win and coverage. It +SHALL remain visible on load and after generation. + +#### Scenario: disclaimer persists across states + +- GIVEN idle and post-generation states +- WHEN each renders +- THEN the disclaimer text remains visible and includes the "no method raises win probability" statement diff --git a/openspec/changes/number-generation-remix/specs/probability-engine/spec.md b/openspec/changes/number-generation-remix/specs/probability-engine/spec.md new file mode 100644 index 0000000..850e5a3 --- /dev/null +++ b/openspec/changes/number-generation-remix/specs/probability-engine/spec.md @@ -0,0 +1,24 @@ +# Delta for Probability Engine + +## ADDED Requirements + +### PM-08: Coverage / Unpopularity Weight Map (Optional) + +The engine MAY produce an optional weight map `w[n]` for generation use, encoding +coverage (favor under-represented numbers within a budget) and, ONLY when +sales/popularity data is available, unpopularity (avoid birthday/sequential +numbers to reduce split risk). When no sales data exists, the map SHALL be neutral +(`w[n] = 1`). This map influences sampling weights only; it SHALL NOT change +computed event probabilities. + +#### Scenario: neutral without sales data + +- GIVEN no sales/popularity import +- WHEN PM-08 runs +- THEN `w[n] = 1` for all n + +#### Scenario: coverage nudges under-represented + +- GIVEN coverage mode enabled +- WHEN PM-08 runs +- THEN under-represented numbers receive weight > 1 within configured bounds diff --git a/openspec/changes/number-generation-remix/specs/statistics-engine/spec.md b/openspec/changes/number-generation-remix/specs/statistics-engine/spec.md new file mode 100644 index 0000000..1b140c3 --- /dev/null +++ b/openspec/changes/number-generation-remix/specs/statistics-engine/spec.md @@ -0,0 +1,23 @@ +# Delta for Statistics Engine + +## ADDED Requirements + +### STE-14: Bias / Fairness Detection + +The engine SHALL compute, over the active draw history, a chi-square +goodness-of-fit test of observed vs uniform number frequencies, a runs test for +sequential independence, and report the existing entropy scalar. Results SHALL be +exposed as a bias report (fair / anomalous) with statistics; they SHALL NOT be +used to alter stored frequencies. + +#### Scenario: fair game reports fair + +- GIVEN 768 draws with frequencies consistent with uniform +- WHEN STE-14 runs +- THEN the bias report flags "fair" with the χ² statistic and p-value + +#### Scenario: anomalous frequency flagged + +- GIVEN a number with observed frequency far beyond uniform expectation +- WHEN STE-14 runs +- THEN the bias report flags "anomalous" and lists the outlier diff --git a/openspec/changes/number-generation-remix/tasks.md b/openspec/changes/number-generation-remix/tasks.md new file mode 100644 index 0000000..691a0d0 --- /dev/null +++ b/openspec/changes/number-generation-remix/tasks.md @@ -0,0 +1,73 @@ +# Tasks: Remix Number Generation on Statistical Levers + +## Review Workload Forecast + +| Field | Value | +|-------|-------| +| Estimated changed lines | 600–900 (new modules + deletions + UI + tests) | +| 400-line budget risk | High | +| Chained PRs recommended | Yes | +| Suggested split | PR1 investigation → PR2 bias → PR3 EV → PR4 coverage → PR5 generator → PR6 retire → PR7 UI | +| Delivery strategy | ask-on-risk | +| Chain strategy | pending | + +Decision needed before apply: Yes +Chained PRs recommended: Yes +Chain strategy: pending +400-line budget risk: High + +### Suggested Work Units + +| Unit | Goal | Likely PR | Focused test command | Runtime harness | Rollback boundary | +|------|------|-----------|----------------------|-----------------|-------------------| +| 1 | Investigate sales source + engine consumers | PR1 | `backend/.venv/bin/pytest tests/ -q` | N/A (research only) | no code shipped | +| 2 | χ²/runs bias detection | PR2 | `backend/.venv/bin/pytest tests/statistics/ -q` | generate stats snapshot via API | revert statistics_service.py | +| 3 | EV service | PR3 | `backend/.venv/bin/pytest tests/ev/ -q` | call EV endpoint on DB | revert ev_service.py | +| 4 | Coverage/unpopularity map | PR4 | `backend/.venv/bin/pytest tests/probability/ -q` | probability generate | revert probability_service.py | +| 5 | Generator remix | PR5 | `backend/.venv/bin/pytest tests/gen/ -q` | POST /gen/generate → 5 combos | revert gen_service/sampling + VERSION | +| 6 | Decouple gen from meta | PR6 | `backend/.venv/bin/pytest tests/gen/ tests/pipeline/ -q` | POST /gen/generate | revert gen_service/sampling | +| 7 | UI disclaimer + EV flag | PR7 | `cd frontend && vitest run` | Mis Números page render | revert component | + +## Phase 1: Investigation + +- [ ] 1.1 Grep all consumers of `meta/`, `feature_engineering/`, `ml/`, `dl/`, `optimization/` outside generation (backtesting/experiment UIs) — record findings. +- [ ] 1.2 Research viable Baloto sales/popularity data source; document obtainability (gates lever B). + +## Phase 2: Statistics — Bias Detection (STE-14) + +- [ ] 2.1 RED: add failing tests for `chi_square` and `runs_test` in `tests/statistics/`. +- [ ] 2.2 GREEN: implement `chi_square`, `runs_test` in `app/statistics/engine.py`; expose bias report via `StatisticsService` (NULL-safe over 768 draws). + +## Phase 3: EV Service (EV-01..03) + +- [ ] 3.1 RED: tests for EV split (`winners>0`), NULL jackpot, `favorable_now`. +- [ ] 3.2 GREEN: create `app/services/ev_service.py` reading `Draw.jackpot`/`winners`; parimutuel split; NULL-safe; return `EVResult`. + +## Phase 4: Probability Coverage Map (PM-08) + +- [ ] 4.1 RED: test neutral map without sales; coverage nudges under-represented. +- [ ] 4.2 GREEN: add optional `coverage/unpopularity` weight map to `probability_service`; neutral when no sales data. + +## Phase 5: Generator Remix (REQ-03, REQ-04) + +- [ ] 5.1 RED: test that `gen_service` no longer reads `meta_selections`; output identical to F5+levers. +- [ ] 5.2 Create `app/generators/weighting.py`: `compose_weights(f5, ev, bias, coverage)` → `dict[int,float]`. +- [ ] 5.3 Modify `gen_service.py` + `sampling.py` (`WeightedPool` takes composed weights; drop `entry.score`); bump `GENERATOR_VERSION`. +- [ ] 5.4 GREEN: pass 5.1 RED test; verify 5 combos via API carry non-null score. + +## Phase 6: Decouple Generation from Meta (keep engines) + +- [ ] 6.1 Remove generation's dependency on `meta_selections`/`meta_selection_entries`: `gen_service` composes weights from F5 + statistical levers, not from a meta selection. (Engines ml/dl/bt/opt/feature are KEPT — they power backtesting/experiment UIs.) +- [ ] 6.2 Make the numbers-orchestrator pipeline skip the `rank` stage for generation (or build a trivial selection) so no meta call is required to produce numbers. +- [ ] 6.3 Keep meta-learning/backtesting/experiment modules and their tests intact; only generation wiring changes. + +## Phase 7: UI (REQ-06) + +- [ ] 7.1 Strengthen Mis Números disclaimer text ("no method raises win probability"). +- [ ] 7.2 Show `favorable_now` / EV flag from generation payload. + +## Phase 8: Verify + +- [ ] 8.1 `backend/.venv/bin/pytest` green; `ruff check .` clean. +- [ ] 8.2 `cd frontend && vitest run` green. +- [ ] 8.3 Manual: generate 5 combos, confirm disclaimer + EV flag, scores populated.