diff --git a/backend/src/backend/app/generators/sampling.py b/backend/src/backend/app/generators/sampling.py index 2a54563..566e7e7 100644 --- a/backend/src/backend/app/generators/sampling.py +++ b/backend/src/backend/app/generators/sampling.py @@ -40,10 +40,9 @@ def super_number_max(self) -> int: ... @dataclass(frozen=True) class WeightedPool: - """A number→probability distribution weighted by an entry score.""" + """A number→weight distribution used for weighted sampling (GEN-009).""" - probabilities: dict[int, float] - score: float + weights: dict[int, float] def sample_combinations( @@ -56,12 +55,13 @@ def sample_combinations( ) -> list[tuple[list[int], int]]: """Generate ``count`` unique valid ``(combination, super_balota)`` pairs. - For each pool, weighted sampling uses ``rng.choices`` with weights derived - from the pool's probability map × entry score. Invalid or duplicate combos - trigger resampling. On each ACCEPTED combination the Superbalota is drawn - ONCE from the same ``isolated_rng(seed)`` stream over ``sb_marginal`` - (D1: post-acceptance draw keeps stream consumption independent of rejection - counts), and the full pair is legality-gated pre-append (D5/R1). + For each pool, weighted sampling uses ``rng.choices`` with the pool's + precomputed ``weights`` map (F5 × cold boost, GEN-009). Invalid or + duplicate combos trigger resampling. On each ACCEPTED combination the + Superbalota is drawn ONCE from the same ``isolated_rng(seed)`` stream over + ``sb_marginal`` (D1: post-acceptance draw keeps stream consumption + independent of rejection counts), and the full pair is legality-gated + pre-append (D5/R1). ``sb_marginal`` maps candidate SB values to relative weights; when ``None`` a uniform distribution over the configured SB range is used. On @@ -83,9 +83,9 @@ def sample_combinations( results: list[tuple[list[int], int]] = [] for pool in pools: - # Build weighted pool: number → (probability × score) - numbers = sorted(pool.probabilities.keys()) - weights = [pool.probabilities[n] * pool.score for n in numbers] + # Build weighted pool directly from the precomputed weights map. + numbers = sorted(pool.weights.keys()) + weights = [float(pool.weights[n]) for n in numbers] needed = count - len(results) for _ in range(needed): diff --git a/backend/src/backend/app/generators/version.py b/backend/src/backend/app/generators/version.py index 4bdace3..8b71c22 100644 --- a/backend/src/backend/app/generators/version.py +++ b/backend/src/backend/app/generators/version.py @@ -1,10 +1,12 @@ """Generator version constant — bumped on algorithm changes only (GEN-009). -2.0.0 (D6): SuperBalota sampling joined the numbers' isolated RNG stream -(R2/D1), changing stream consumption — output identity (``generation_seed`` / -``snapshot_fingerprint``) differs from every pre-2.0.0 value. +3.0.0 (GEN-009 remix): dropped the meta prediction-chain ``entry.score`` from +sampling/allocation. Per-number weights are now F5 frequency × cold-coverage +boost (PM-08), computed transparently from draw history. Output identity +(``generation_seed`` / ``snapshot_fingerprint``) differs from every pre-3.0.0 +value. """ from __future__ import annotations -GENERATOR_VERSION: str = "2.0.0" +GENERATOR_VERSION: str = "3.0.0" diff --git a/backend/src/backend/app/generators/weighting.py b/backend/src/backend/app/generators/weighting.py new file mode 100644 index 0000000..a425c9b --- /dev/null +++ b/backend/src/backend/app/generators/weighting.py @@ -0,0 +1,34 @@ +"""Generation weight construction (GEN-09): legitimate statistical levers only. + +Builds the per-number sampling weights consumed by ``sampling.WeightedPool``. +The remix replaces the retired ``entry.score`` meta chain: a number's weight is +now a transparent function of draw history (F5 frequency × an optional cold +coverage boost), never a meta model score. +""" + +from __future__ import annotations + +from decimal import Decimal + +COLD_BOOST: Decimal = Decimal("1.5") +"""Multiplier applied to COLD numbers to lift their sampling weight (PM-08).""" + + +def build_weights( + probabilities: dict[int, float], + coverage: dict[int, str], + *, + cold_boost: Decimal = COLD_BOOST, +) -> dict[int, float]: + """Combine F5 probabilities with a cold-coverage boost into final weights. + + ``probabilities`` is the F5 number→probability map (probability engine). + ``coverage`` maps each number to ``"cold"``/``"normal"``/``"hot"``; only + ``"cold"`` numbers receive the ``cold_boost`` multiplier (lever C). The + result preserves the key set of ``probabilities``. + """ + boost = float(cold_boost) + return { + number: probability * (boost if coverage.get(number) == "cold" else 1.0) + for number, probability in probabilities.items() + } 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/gen_service.py b/backend/src/backend/app/services/gen_service.py index 34b7be4..7feeebb 100644 --- a/backend/src/backend/app/services/gen_service.py +++ b/backend/src/backend/app/services/gen_service.py @@ -31,8 +31,12 @@ from backend.app.generators.snapshot_store import GenSnapshotStore from backend.app.generators.validation import validate_combination from backend.app.generators.version import GENERATOR_VERSION +from backend.app.generators.weighting import build_weights from backend.app.models.gen_snapshot import GenSnapshot +from backend.app.services.probability_service import _classify_coverage +from backend.app.repositories.stat_payload_repository import StatPayloadRepository from backend.app.services.errors import GenServiceError +from backend.app.statistics.engine import frequency DEFAULT_COUNT: int = 10 """Default combination count when not provided (GEN-002).""" @@ -120,22 +124,23 @@ def generate( ) -> GenerationResult: """Generate (or idempotently return) a lottery combination snapshot. - Pipeline (GEN-001): resolve the F12 selection → validate count → - allocate via the micro-unit rule (GEN-004) → load the F5 distribution → - load the SB historical marginal (R2/D2) → sample ``(combo, sb)`` pairs - with ``isolated_rng`` (GEN-005, D1) → compute the selection-weighted - score (R3/D3) → gate legality pre-persist (R1/D5) → fingerprint → - persist a NEW active version atomically. Same inputs reproduce the - identical snapshot including Superbalotas and scores (GEN-008); a - duplicate non-active fingerprint is a conflict (GEN-013). + Pipeline (GEN-001, GEN-009 remix): resolve the selection scope → validate + count → load the F5 distribution → derive per-number weights from F5 × + cold-coverage boost (PM-08) → sample ``(combo, sb)`` pairs with + ``isolated_rng`` (GEN-005, D1) → score each combo with its transparent + mean sampling weight (R3/D3) → gate legality pre-persist (R1/D5) → + fingerprint → persist a NEW active version atomically. The meta + prediction-chain scores are NOT used (audit-proven zero effect). Same + inputs reproduce the identical snapshot including Superbalotas and scores + (GEN-008); a duplicate non-active fingerprint is a conflict (GEN-013). """ lottery = self._resolve_lottery(lottery_id) effective_count = DEFAULT_COUNT if count is None else count self._validate_count(effective_count) selection = self._resolve_selection(lottery_id, selection_id) - entry_rows = self._read_selection_entries(selection.id) - entries = [SelectionEntry(score=row.score, rank=row.rank) for row in entry_rows] - allocations = allocate_count(entries, effective_count) + # GEN-09 remix: a single allocation unit carries the whole count; the + # per-number weights (not a meta entry score) drive sampling below. + allocations = allocate_count([SelectionEntry(score=1.0, rank=0)], effective_count) effective_seed = ( generation_seed(selection.fingerprint, lottery_id, effective_count, GENERATOR_VERSION) @@ -161,28 +166,29 @@ def generate( ) probabilities = self._load_distribution(lottery_id) - sb_marginal = self._load_sb_marginal(lottery) - pools = [ - WeightedPool(probabilities=probabilities, score=entries[i].score) - for i, allocated in allocations - if allocated > 0 + # Coverage (COLD/NORMAL/HOT) from draw history; only COLD numbers get a + # boost (PM-08). With no imported draw numbers this map is all "normal". + draws = [ + numbers + for _dn, numbers, _j, _w in StatPayloadRepository(self._session).iter_draws(lottery.id) ] + coverage = _classify_coverage( + frequency(draws), + lottery.min_number, + lottery.max_number, + lottery.numbers_to_select, + ) + weights = build_weights(probabilities, coverage) + sb_marginal = self._load_sb_marginal(lottery) + pools = [WeightedPool(weights=weights) for _i, allocated in allocations if allocated > 0] sampled = sample_combinations(effective_seed, pools, effective_count, lottery, sb_marginal) - # D3: score = entry_score × mean(P(n)) computed where pools carry both - # inputs. Pools consume the sample sequentially in allocation order, so - # each pair's provenance (entry score) is recovered positionally. + # D3/R3: score is the transparent mean sampling weight of the combo's + # numbers (F5 × cold boost) — no meta entry score involved. scored: list[tuple[list[int], int, float]] = [] - idx = 0 - for entry_index, allocated in allocations: - if allocated <= 0: - continue - entry_score = float(entries[entry_index].score) - for _ in range(allocated): - combo, sb = sampled[idx] - idx += 1 - mean_p = sum(probabilities.get(n, 0.0) for n in combo) / len(combo) - scored.append((combo, sb, round(entry_score * mean_p, 6))) + for combo, sb in sampled: + mean_w = sum(weights.get(n, 0.0) for n in combo) / len(combo) + scored.append((combo, sb, round(mean_w, 6))) # R1/D5: legality assert before anything is persisted. for combo, sb, _score in scored: @@ -341,23 +347,6 @@ def _resolve_selection(self, lottery_id: int, selection_id: int | None) -> Any: ) return selection - def _read_selection_entries(self, selection_id: int) -> list[Any]: - """Read the scored entries of a selection; ``GEN_NO_SELECTION`` when empty.""" - from backend.app.models.meta_selection_entry import MetaSelectionEntry - - stmt = ( - select(MetaSelectionEntry) - .where(MetaSelectionEntry.selection_id == selection_id) - .order_by(MetaSelectionEntry.rank) - ) - rows = list(self._session.execute(stmt).scalars().all()) - if not rows: - raise GenServiceError( - GenServiceError.GEN_NO_SELECTION, - f"selection {selection_id} has no entries", - ) - return rows - def _load_distribution(self, lottery_id: int) -> dict[int, float]: """Read the active F5 number→probability map; ``GEN_NO_DISTRIBUTION`` absent. diff --git a/backend/src/backend/app/services/meta_service.py b/backend/src/backend/app/services/meta_service.py index 4f84829..22c958c 100644 --- a/backend/src/backend/app/services/meta_service.py +++ b/backend/src/backend/app/services/meta_service.py @@ -10,6 +10,7 @@ import json from dataclasses import dataclass, field +from datetime import UTC, datetime from typing import Any from sqlalchemy.orm import Session @@ -136,6 +137,13 @@ def rank( # 5. Idempotency check existing = self._store.find_by_fingerprint(fp) if existing is not None: + # D8 healing: refresh created_at so a rerank against a newer + # bt_snapshot is not blocked by a stale timestamp. The ranking + # content (fingerprint) is identical, so it stays valid for the + # current backtest context. + existing.created_at = datetime.now(UTC) + self._session.add(existing) + self._session.commit() return RankingResult( ranking_id=existing.id, lottery_id=lottery_id, 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/gen/test_gen_generate.py b/backend/tests/gen/test_gen_generate.py index 87f8522..b66a42d 100644 --- a/backend/tests/gen/test_gen_generate.py +++ b/backend/tests/gen/test_gen_generate.py @@ -153,17 +153,17 @@ def test_generation_byte_reproducible_including_sb(self, db: Session, seed_gen_d assert pairs_a == pairs_b def test_score_formula_selection_weighted(self, db: Session, seed_gen_data) -> None: - """score == round(entry_score × mean(P(n)), 6) with uniform P=0.05 (D3).""" + """score == round(mean(weights), 6); uniform P=0.05 → score 0.05 (GEN-009).""" ids = seed_gen_data(scores=(0.7, 0.3)) result = _service(db).generate(lottery_id=ids["lottery_id"], count=1) - expected = round(0.7 * ((0.05 * 6) / 6), 6) + expected = round(0.05, 6) assert result.combinations[0].score == expected - def test_score_reflects_both_entry_weights(self, db: Session, seed_gen_data) -> None: - """count=10 spans both entries → scores ∈ {0.7×0.05, 0.3×0.05} rounded (D3).""" + def test_score_reflects_weights_not_entries(self, db: Session, seed_gen_data) -> None: + """count=10 → all scores equal the mean F5 weight (0.05), entry scores ignored (GEN-009).""" ids = seed_gen_data(scores=(0.7, 0.3)) result = _service(db).generate(lottery_id=ids["lottery_id"], count=10) - allowed = {round(0.7 * 0.05, 6), round(0.3 * 0.05, 6)} + allowed = {round(0.05, 6)} for row in result.combinations: assert row.score in allowed diff --git a/backend/tests/gen/test_identity.py b/backend/tests/gen/test_identity.py index 5d13a42..fc8ea7a 100644 --- a/backend/tests/gen/test_identity.py +++ b/backend/tests/gen/test_identity.py @@ -37,9 +37,9 @@ PRE_CHANGE_SEED = 297872213468358109463619875798332175481 PRE_CHANGE_SNAPSHOT_FINGERPRINT = "ddd2dbe5c6c8002067c2191e118caf1902c9c2e9b9ee2616136119bda3feb42c" -# Regenerated v2.0.0 golden vectors (D6) — locked atomically with the bump. -GOLDEN_SEED_V2 = 275000497823893291003335902595522194545 -GOLDEN_SNAPSHOT_FINGERPRINT_V2 = "3a767d0a41419b566bc718e64821d59a698321355b4d0da533b0435b22b48373" +# Regenerated v3.0.0 golden vectors (GEN-009 remix) — locked atomically with the bump. +GOLDEN_SEED_V3 = 198708973693754007559308447754739185303 +GOLDEN_SNAPSHOT_FINGERPRINT_V3 = "570bb8de864fb28b85027badc4552ebaa2e4c3bf6504b4cef649b1c056542ae7" class TestGenerationSeed: @@ -68,13 +68,13 @@ def test_locked_pre_change_golden_vector(self) -> None: ) assert seed == PRE_CHANGE_SEED - def test_locked_v2_golden_vector(self) -> None: - """Regenerated golden under the bumped identity (D6).""" - assert GENERATOR_VERSION == "2.0.0" + def test_locked_v3_golden_vector(self) -> None: + """Regenerated golden under the bumped identity (GEN-009 remix).""" + assert GENERATOR_VERSION == "3.0.0" seed = generation_seed( GOLDEN_SELECTION_FINGERPRINT, GOLDEN_LOTTERY_ID, GOLDEN_COUNT, GENERATOR_VERSION ) - assert seed == GOLDEN_SEED_V2 + assert seed == GOLDEN_SEED_V3 @pytest.mark.parametrize( ("selection_fingerprint", "lottery_id", "count", "version"), @@ -157,16 +157,16 @@ def test_locked_pre_change_golden_vector(self) -> None: ) assert fp == PRE_CHANGE_SNAPSHOT_FINGERPRINT - def test_locked_v2_golden_vector(self) -> None: - """Regenerated fingerprint under the bumped identity (D6).""" - assert GENERATOR_VERSION == "2.0.0" - seed_v2 = generation_seed( + def test_locked_v3_golden_vector(self) -> None: + """Regenerated fingerprint under the bumped identity (GEN-009 remix).""" + assert GENERATOR_VERSION == "3.0.0" + seed_v3 = generation_seed( GOLDEN_SELECTION_FINGERPRINT, GOLDEN_LOTTERY_ID, GOLDEN_COUNT, GENERATOR_VERSION ) fp = snapshot_fingerprint( - GOLDEN_LOTTERY_ID, GOLDEN_SELECTION_ID, GOLDEN_COUNT, seed_v2, GENERATOR_VERSION + GOLDEN_LOTTERY_ID, GOLDEN_SELECTION_ID, GOLDEN_COUNT, seed_v3, GENERATOR_VERSION ) - assert fp == GOLDEN_SNAPSHOT_FINGERPRINT_V2 + assert fp == GOLDEN_SNAPSHOT_FINGERPRINT_V3 @pytest.mark.parametrize( ("lottery_id", "selection_id", "count", "seed", "version"), @@ -228,9 +228,9 @@ def test_matches_canonical_sha256_formula(self) -> None: class TestVersionBumpAliasingGuard: """D6/R2 — v2 outputs MUST NOT alias any pre-change fixture fingerprint.""" - def test_v2_fingerprint_differs_from_pre_change(self) -> None: + def test_v3_fingerprint_differs_from_pre_change(self) -> None: """Same canonical inputs → bump moves the fingerprint away from legacy.""" - assert GENERATOR_VERSION == "2.0.0" + assert GENERATOR_VERSION == "3.0.0" seed_v2 = generation_seed( GOLDEN_SELECTION_FINGERPRINT, GOLDEN_LOTTERY_ID, GOLDEN_COUNT, GENERATOR_VERSION ) diff --git a/backend/tests/gen/test_sampling.py b/backend/tests/gen/test_sampling.py index df0c84a..083cdf5 100644 --- a/backend/tests/gen/test_sampling.py +++ b/backend/tests/gen/test_sampling.py @@ -42,10 +42,7 @@ def cfg(self) -> LotteryConfig: def _make_pool(self, n: int = 49, weight: float = 1.0) -> WeightedPool: """Create a uniform distribution over numbers 1..n.""" - return WeightedPool( - probabilities={i: weight for i in range(1, n + 1)}, - score=1.0, - ) + return WeightedPool(weights={i: weight for i in range(1, n + 1)}) def test_determinism(self, cfg: LotteryConfig) -> None: """Same seed → identical output (GEN-005, NFR-GEN-01).""" @@ -91,13 +88,13 @@ def test_sb_drawn_on_same_stream_after_acceptance(self, cfg: LotteryConfig) -> N consume SB draws (post-acceptance draw). White-box replay of the documented algorithm with a single random.Random(seed) instance. """ - pool = WeightedPool(probabilities={i: 1.0 for i in range(1, 50)}, score=1.0) + pool = WeightedPool(weights={i: 1.0 for i in range(1, 50)}) count = 4 result = sample_combinations(1234, [pool], count, cfg) rng = random.Random(1234) - numbers = sorted(pool.probabilities) - weights = [pool.probabilities[n] * pool.score for n in numbers] + numbers = sorted(pool.weights) + weights = [pool.weights[n] for n in numbers] sb_numbers = list(range(cfg.super_number_min, cfg.super_number_max + 1)) sb_weights = [1.0 / len(sb_numbers)] * len(sb_numbers) generated: set[frozenset[int]] = set() @@ -140,10 +137,7 @@ def test_max_attempts_exhaustion(self) -> None: super_number_min=1, super_number_max=9, ) - pool = WeightedPool( - probabilities={i: 1.0 for i in range(1, 7)}, - score=1.0, - ) + pool = WeightedPool(weights={i: 1.0 for i in range(1, 7)}) with pytest.raises(GenServiceError) as exc_info: sample_combinations(42, [pool], 5, tiny_cfg, max_attempts=3) assert exc_info.value.code == "GEN_SPACE_EXHAUSTED" @@ -157,15 +151,9 @@ def test_no_duplicates_in_output(self, cfg: LotteryConfig) -> None: def test_multiple_pools(self, cfg: LotteryConfig) -> None: """Multiple weighted pools produce combinations from each.""" - pool1 = WeightedPool( - probabilities={i: 1.0 for i in range(1, 50)}, - score=0.7, - ) - pool2 = WeightedPool( - probabilities={i: 1.0 for i in range(1, 50)}, - score=0.3, - ) - # 3 from pool1, 2 from pool2 = 5 total + pool1 = WeightedPool(weights={i: 0.7 for i in range(1, 50)}) + pool2 = WeightedPool(weights={i: 0.3 for i in range(1, 50)}) + # The first pool satisfies the full count; pool2 is unused. results = sample_combinations(42, [pool1, pool2], 5, cfg) assert len(results) == 5 @@ -173,8 +161,7 @@ def test_score_influences_distribution(self) -> None: """Higher score weight biases number selection.""" # Pool with strong weight on low numbers pool_low = WeightedPool( - probabilities={i: (10.0 if i <= 10 else 0.1) for i in range(1, 50)}, - score=1.0, + weights={i: (10.0 if i <= 10 else 0.1) for i in range(1, 50)} ) cfg = LotteryConfig( numbers_to_select=3, diff --git a/backend/tests/gen/test_types.py b/backend/tests/gen/test_types.py index 8bd3ad8..e305fb3 100644 --- a/backend/tests/gen/test_types.py +++ b/backend/tests/gen/test_types.py @@ -94,10 +94,10 @@ def test_inequality_different_count(self) -> None: class TestGeneratorVersion: """GENERATOR_VERSION constant — GEN-009 determinism + D6 major bump.""" - def test_version_is_2_0_0(self) -> None: - # D6: stream-consumption change (SB on the shared stream) is a breaking - # output-identity change → GENERATOR_VERSION "2.0.0". - assert GENERATOR_VERSION == "2.0.0" + def test_version_is_3_0_0(self) -> None: + # GEN-009 remix: dropping meta entry.score is a breaking output-identity + # change → GENERATOR_VERSION "3.0.0". + assert GENERATOR_VERSION == "3.0.0" def test_version_is_string(self) -> None: assert isinstance(GENERATOR_VERSION, str) diff --git a/backend/tests/generators/test_weighting.py b/backend/tests/generators/test_weighting.py new file mode 100644 index 0000000..e43d28b --- /dev/null +++ b/backend/tests/generators/test_weighting.py @@ -0,0 +1,28 @@ +"""Unit tests for generator weight construction (GEN-09).""" + +from __future__ import annotations + +from backend.app.generators.weighting import COLD_BOOST, build_weights + + +def test_build_weights_applies_cold_boost_only() -> None: + probabilities = {1: 0.5, 2: 0.5} + coverage = {1: "cold", 2: "normal"} + weights = build_weights(probabilities, coverage) + assert weights[1] == 0.5 * float(COLD_BOOST) + assert weights[2] == 0.5 + + +def test_build_weights_ignores_hot_and_normal() -> None: + probabilities = {1: 0.3, 2: 0.7} + coverage = {1: "normal", 2: "hot"} + weights = build_weights(probabilities, coverage) + assert weights == {1: 0.3, 2: 0.7} + + +def test_build_weights_preserves_key_set() -> None: + probabilities = {1: 0.2, 2: 0.3, 3: 0.5} + coverage = {1: "cold", 2: "normal", 3: "hot"} + weights = build_weights(probabilities, coverage) + assert set(weights) == {1, 2, 3} + assert weights[1] == 0.2 * float(COLD_BOOST) diff --git a/backend/tests/meta/test_meta_service.py b/backend/tests/meta/test_meta_service.py index 1adea38..d22e454 100644 --- a/backend/tests/meta/test_meta_service.py +++ b/backend/tests/meta/test_meta_service.py @@ -336,3 +336,45 @@ def test_get_selection_infers_context_hash_from_first(self, db, seeded_lottery) snapshot = svc.get_selection(1) assert snapshot.context_hash == "ctx-hash" + + +def test_rank_refreshes_created_at_on_idempotent_return(db, seeded_lottery, service): + """Regression (D8 healing): a second rank() for an unchanged context must + refresh the ranking created_at so it is not perpetually 'stale' vs a newer + bt_snapshot. Previously the idempotent return kept the old timestamp, which + made pipeline_service._ranking_stale report stale forever after bt re-ran + (PIPE_STAGE_FAILED: ranking stale for backtest context after one rerank).""" + from datetime import datetime + + from backend.app.models.bt_snapshot import BtSnapshot + from backend.app.models.meta_ranking import MetaRanking + + _seed_bt( + db, + "s1", + {"sharpe": 1.2, "win_rate": 0.5, "max_drawdown": 0.1, "profit_factor": 1.5}, + ) + r1 = service.rank(lottery_id=seeded_lottery.id) + ranking_id = r1.ranking_id + + # Simulate a newer bt_snapshot (the exact condition that previously broke): + # bt "re-ran" and produced a snapshot with a later created_at but identical + # data, so the ranking fingerprint is unchanged (idempotent return path). + mid = datetime.now() + snap1 = db.query(BtSnapshot).filter_by(lottery_id=1).first() + snap1.created_at = mid + db.commit() + + # Force the existing ranking into the past to reproduce staleness. + row = db.get(MetaRanking, ranking_id) + row.created_at = datetime(2000, 1, 1) + db.commit() + + r2 = service.rank(lottery_id=seeded_lottery.id) + assert r2.ranking_id == ranking_id + db.refresh(row) + # The idempotent return must refresh created_at so it is no longer stale + # vs the newer bt_snapshot (row.created_at is now, which is >= mid). + assert row.created_at >= mid, ( + "rank() must refresh created_at so the ranking is not stale vs newest bt" + ) 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..84161be --- /dev/null +++ b/docs/BALOTO_RULES_AND_ENGINE_AUDIT.md @@ -0,0 +1,229 @@ +# 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. + +> **Status — number-generation-remix: IMPLEMENTED (2026-08-25).** The dead +> prediction chain (`features → ml → dl → bt → rank → select → opt`) was removed +> from generation. `gen` now samples `F5 (probability) × cold-coverage boost`; the +> meta `entry.score` is no longer used. Stacked PRs #71 / #72 / #73 are merged into +> `fix/rank-stale-healing`. Final shape in §7. + +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 (pre-remix, historical) + +> Run on the live repo **before** the number-generation-remix. Its conclusion — +> the prediction chain had zero effect on the numbers `gen` outputs — is exactly +> what motivated the change in §4/§7. Read it as the diagnosis, not the 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. + +--- + +## 7. Implementation — number-generation-remix (DONE) + +The refactor is implemented and merged. Generation no longer depends on the meta +prediction chain; the 5 options are built only on `stats` (F5) and `probability` +(coverage) — legitimate statistical levers — and the UI is honest about odds. + +### Quick path (review) + +1. Read `GenService.generate` — weights come from `build_weights(probabilities, coverage)`. +2. Confirm `score` is the transparent **mean sampling weight**, not a win probability. +3. Confirm the UI (`Mis Números`) states odds are unchanged and labels `Score` → `Peso`. + +### What changed + +| Area | Before | After | +|------|--------|-------| +| Generation weights | F5 sampled by `entry.score` (uniform inside a pool → no effect) | F5 × cold-coverage boost (`build_weights`) | +| Meta chain (`ml`/`dl`/`bt`/`rank`/`select`/`opt`) | present but inert in `gen` | removed from the generation path | +| Bias diagnostic | absent | `chi_square_gof`, `runs_test` (per-draw **sum** series), `bias_report` (STE-14) | +| EV (lever A) | absent | `ev_service` (`combinations_count`, `combination_ev`, `estimate_ticket_ev`, `is_high_ev_window`) | +| Coverage (lever C/D) | absent | `coverage_map` + `cold_boost_weights` (PM-08) | +| UI | implied meta pipeline | honest disclaimer + Transparencia panel; `Score` → `Peso` | + +### Honesty checklist + +- [x] UI states no method raises win probability. +- [x] `score` column labeled as coverage weight, not prediction. +- [x] `GENERATOR_VERSION` bumped 2.0.0 → 3.0.0; golden vectors regenerated. +- [x] Engines `ml`/`dl`/`bt`/`opt`/`feature` retained (consumed by backtesting/experiment UIs); only `gen` was decoupled. + +### Next step + +- Land `fix/rank-stale-healing` → `main` when the release window opens. +- If sales/popularity data is ever imported, revisit lever B (unpopularity weights). diff --git a/frontend/src/pages/MisNumeros.test.tsx b/frontend/src/pages/MisNumeros.test.tsx index a483cba..f5e6a21 100644 --- a/frontend/src/pages/MisNumeros.test.tsx +++ b/frontend/src/pages/MisNumeros.test.tsx @@ -204,7 +204,7 @@ describe("Mis Números page", () => { ).not.toBeInTheDocument(); // Page stays interactive: CTA re-enabled and disclaimer still visible. expect(screen.getByRole("button", { name: /generate numbers/i })).toBeEnabled(); - expect(screen.getByText(/remain/i)).toBeInTheDocument(); + expect(screen.getByText(/completamente aleatorios/i)).toBeInTheDocument(); }); it("labels every ticket as valid for both draws and offers no toggle (R3)", async () => { @@ -255,13 +255,13 @@ describe("Mis Números page", () => { selectLottery(); render(); - expect(screen.getByText(/statistically informed/i)).toBeInTheDocument(); + expect(screen.getByText(/completamente aleatorios/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /generate numbers/i })); await screen.findByRole("table", { name: /generated combinations/i }); - expect(screen.getByText(/statistically informed/i)).toBeInTheDocument(); - expect(screen.getByText(/random/i)).toBeInTheDocument(); + expect(screen.getByText(/completamente aleatorios/i)).toBeInTheDocument(); + expect(screen.getByText(/aleatorios/i)).toBeInTheDocument(); }); it("prompts to select a lottery with a disabled CTA when none is chosen", async () => { diff --git a/frontend/src/pages/MisNumeros.tsx b/frontend/src/pages/MisNumeros.tsx index bb0f7bc..facd509 100644 --- a/frontend/src/pages/MisNumeros.tsx +++ b/frontend/src/pages/MisNumeros.tsx @@ -16,7 +16,7 @@ const IDLE_HINT = "Click Generate numbers to run the full analysis chain and bui /** Owner decision: every ticket is valid for BOTH draws; no toggle exists. */ const DUAL_DRAW_LABEL = "Un boleto, dos sorteos (Baloto + Revancha)"; const DISCLAIMER_TEXT = - "Combinations are statistically informed by historical draws, but official draws remain completely random: no method improves prediction odds and no outcome is promised."; + "Los números se generan a partir de la frecuencia histórica (F5) y un refuerzo de números fríos para mejorar la cobertura. Los sorteos oficiales son completamente aleatorios: ningún método mejora la probabilidad de acierto y ningún resultado está garantizado."; /** Default combination count sent in the payload (R4/D11). */ const DEFAULT_COUNT = 5; const BUTTON_CLASS = @@ -39,8 +39,12 @@ const combinationColumns: DataColumn[] = [ }, { key: "score", - label: "Score", - render: (row) => row.score?.toFixed(2) ?? "—", + label: "Peso", + render: (row) => ( + + {row.score?.toFixed(2) ?? "—"} + + ), }, ]; @@ -181,8 +185,8 @@ export default function MisNumeros() {

Mis Números

- One request runs stats → features → ml → dl → backtesting → rank → select → generate for - the selected lottery. + Una solicitud calcula la frecuencia histórica (estadísticas + probabilidad F5) y genera + combinaciones con cobertura reforzada para el sorteo seleccionado.