where('email', $email)->first(); if ($user === null) { fwrite(STDERR, "BENCH user not found: {$email}\n"); exit(1); } /** @var RuleSuggestionAggregator $aggregator */ $aggregator = app(RuleSuggestionAggregator::class); /** @var RuleSuggestionGuard $guard */ $guard = app(RuleSuggestionGuard::class); /** @var TransactionMatcher $matcher */ $matcher = app(TransactionMatcher::class); $total = $matcher->total($user); $groups = $aggregator->groupsFor($user); $categories = $aggregator->categoryOptions($user); $reachable = array_sum(array_column($groups, 'count')); $overbroad = (float) config('ai_suggestions.overbroad_fraction'); /** * Frozen list of structural Spanish-bank / geographic noise words that carry no * merchant or category identity. Excluding them stops the oracle from picking a * generic descriptor (e.g. "pago", "tarjeta") that would span unrelated * merchants and wildly overstate reachable coverage. FROZEN — do not tune. * * @var array */ $STOP = array_fill_keys([ 'pago', 'pagos', 'compra', 'compras', 'recibo', 'recibos', 'adeudo', 'adeudos', 'transferencia', 'transferencias', 'realizada', 'realizado', 'emitida', 'recibida', 'devolucion', 'devolución', 'tarjeta', 'tarj', 'debito', 'débito', 'credito', 'crédito', 'efectivo', 'cajero', 'cajeros', 'ingreso', 'ingresos', 'traspaso', 'traspasos', 'para', 'por', 'con', 'del', 'las', 'los', 'una', 'uno', 'unos', 'unas', 'que', 'num', 'nro', 'ref', 'concepto', 'fecha', 'importe', 'cuenta', 'banco', 'comision', 'comisión', 'madrid', 'barcelona', 'sevilla', 'valencia', 'malaga', 'bilbao', 'esp', 'espana', 'españa', 'spain', 'www', 'http', 'https', 'eur', 'usd', 'slu', 'mediante', ], true); /** * Frozen oracle: pick the most distinctive safe match token for a group. * - counterparty fields: the whole (clean) key, matched with `equals`. * - description: the non-noise key word that matches the most transactions * without tripping the over-broad guard (ties broken by longer word). The * noise stoplist keeps the choice on a merchant/category-bearing token, * approximating a competent model reading a messy bank descriptor. * * @return array{0:string,1:string}|null [operator, token] */ $pickToken = function (array $group) use ($matcher, $user, $total, $overbroad, $STOP): ?array { $field = $group['field']; if ($field !== 'description') { $token = $group['key']; $count = $matcher->countMatching($user, $field, 'equals', $token); if ($count >= 1 && $total > 0 && ($count / $total) <= $overbroad) { return ['equals', $token]; } return null; } $words = array_values(array_unique(array_filter( explode(' ', $group['key']), fn (string $w): bool => mb_strlen($w) >= 3 && ! isset($STOP[$w]), ))); $best = null; $bestCount = 0; $bestLen = 0; foreach ($words as $word) { $count = $matcher->countMatching($user, 'description', 'contains', $word); if ($count < 1 || ($total > 0 && ($count / $total) > $overbroad)) { continue; } $len = mb_strlen($word); if ($count > $bestCount || ($count === $bestCount && $len > $bestLen)) { $best = $word; $bestCount = $count; $bestLen = $len; } } return $best === null ? null : ['contains', $best]; }; $rawSuggestions = []; foreach ($groups as $group) { $picked = $pickToken($group); if ($picked === null) { continue; } [$operator, $token] = $picked; $rawSuggestions[] = [ 'group_key' => $group['key'], 'match_field' => $group['field'], 'match_operator' => $operator, 'match_token' => $token, // Propose a new category so the guard's category step never binds — // we are measuring the token/clustering ceiling, not category mapping. 'category_id' => '', 'new_category_name' => mb_substr('grp '.$group['key'], 0, 255), 'new_category_direction' => $group['direction'] === 'inflow' ? 'inflow' : 'outflow', 'confidence' => 1.0, ]; } $validated = $guard->validate($user, $rawSuggestions, $categories); $matchedIds = []; foreach ($validated as $suggestion) { $ids = $matcher ->matching($user, $suggestion['match_field'], $suggestion['match_operator'], $suggestion['match_token']) ->pluck('id'); foreach ($ids as $id) { $matchedIds[$id] = true; } } $oracleTx = count($matchedIds); $coverage = $total > 0 ? round($oracleTx / $total * 100, 2) : 0.0; echo 'METRIC oracle_tx='.$oracleTx."\n"; echo 'METRIC reachable_tx='.$reachable."\n"; echo 'METRIC total_uncat='.$total."\n"; echo 'METRIC groups_sent='.count($groups)."\n"; echo 'METRIC validated_count='.count($validated)."\n"; echo 'METRIC coverage_pct='.$coverage."\n";