From c58689ae95b80a660d4889571841c2ffe7e01327 Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:37:35 +0200 Subject: [PATCH 1/6] test(career): reprend la sonde profondeur comfort/best sur develop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reprise telle quelle de `test/sondes-adversariales-constats` (commit 13a3b0e) comme point de départ mesuré. Elle est VERTE 6/6 sur `origin/develop` : ce n'est pas un test rouge à faire passer, c'est un test de *caractérisation* qui fige le comportement actuel, défaut compris (`expect(rhythm, 0)` sur la séance normale sous le seuil). Chiffres relevés sur `origin/develop`, avec comfort=mid / best=throat : - normal sr=0.50 : rhythm=0/300 hold=300/300 - normal sr=0.80 : rhythm=300/300 hold=300/300 - égalité sr=0.70 : rhythm=16/300 (5.3 %, ≈ 1/14 = la loterie de surcharge) - Encore sr=0.50 : rhythm=300/300 hold=300/300 - Encore sr=0.80 : rhythm=300/300 hold=300/300 - Utilise-moi 0.50 : rhythm=300/300 hold=300/300 Commité avant tout correctif pour que le diff suivant montre exactement quel `expect` bascule, et pourquoi. --- ...capability_depth_comfort_vs_best_test.dart | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 rhythm_coach/test/capability_depth_comfort_vs_best_test.dart diff --git a/rhythm_coach/test/capability_depth_comfort_vs_best_test.dart b/rhythm_coach/test/capability_depth_comfort_vs_best_test.dart new file mode 100644 index 0000000..53cb1cc --- /dev/null +++ b/rhythm_coach/test/capability_depth_comfort_vs_best_test.dart @@ -0,0 +1,285 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:beat_bitch/career/models/career_generation_inputs.dart'; +import 'package:beat_bitch/career/models/phrase_bank.dart'; +import 'package:beat_bitch/career/services/generation/career_session_generator.dart'; +import 'package:beat_bitch/services/capability_service.dart'; + +/// Sonde adversariale — rejeu du constat « grave » du tri du 2026-08-18 +/// (verrou de profondeur : le générateur cible `comfort`, pas `best`). +/// +/// Prédiction écrite avant exécution : avec `comfort=mid`/`best=throat`, +/// une séance normale ne devrait produire un step visant `throat` que si +/// `rhythmDepthMax` est l'axe surchargé de la séance ET que sa +/// `successRate` franchit `kDepthCranGate` (0.65) — jamais en dessous. +/// En séance `intense` (Encore) le bonus de cran est inconditionnel dans +/// `capabilityCapFor`, mais `maxDepthIndex` (plafond de *tirage*, distinct +/// du clamp) reste dérivé du `comfort` brut pour Encore (`useMe=false`) — +/// contrairement à Utilise-moi qui le force à `full`. Hypothèse à +/// trancher ici : Encore seul peut donc ne JAMAIS tirer `throat`, même si +/// `capabilityCapFor` l'autoriserait — un désaccord avec le rapport, qui +/// annonce 300/300 pour « Utilise-moi ou Encore » sans distinguer les deux. + +List _p(List texts) => + texts.map((t) => PhraseEntry(text: t)).toList(); + +PhraseBank _bank() => PhraseBank( + byMode: { + for (final m in SessionMode.values) + m: { + 'soft': _p(['s']), + 'medium': _p(['m']), + 'hard': _p(['h']), + 'boost': _p(['b']), + 'finale': _p(['f']), + }, + }, + congrats: _p(['bravo']), + intros: _p(['intro']), + ); + +CapabilityAxisState _s(double v, {double sr = 0.5}) => + CapabilityAxisState(best: v, comfort: v, successRate: sr); + +/// Profil réaliste : les 14 axes surchargeables sont tous renseignés (comme +/// une joueuse avec un historique), pour que `rhythmDepthMax` entre en +/// concurrence pour le tirage d'axe surchargé au lieu d'être le seul +/// candidat (ce qui gonflerait artificiellement son taux de sélection). +CapabilityProfile _profile(double depthSuccessRate) { + return CapabilityProfile({ + CapabilityAxis.rhythmDepthMax: CapabilityAxisState( + best: Position.throat.index.toDouble(), + comfort: Position.mid.index.toDouble(), + successRate: depthSuccessRate, + ), + CapabilityAxis.gorgeApneeStreak: _s(24), + CapabilityAxis.gorgeEngagementStreak: _s(35), + CapabilityAxis.gorgeCrossingsBpmThroat: _s(90), + CapabilityAxis.gorgeCrossingsBpmFull: _s(80), + CapabilityAxis.rhythmBpmCeilShallow: _s(100), + CapabilityAxis.rhythmBpmCeilThroat: _s(90), + CapabilityAxis.rhythmBpmCeilFull: _s(80), + CapabilityAxis.rhythmMotionStreak: _s(200), + CapabilityAxis.holdThroatStreak: _s(30), + CapabilityAxis.holdFullStreak: _s(25), + CapabilityAxis.noswallowStreak: _s(120), + CapabilityAxis.biffleStreak: _s(30), + CapabilityAxis.biffleBpmMax: _s(90), + }); +} + +/// Variante « à égalité » : les 14 axes surchargeables partagent la MÊME +/// `successRate` (dont `rhythmDepthMax`). Isole le tirage lui-même : si +/// tous les axes sont à égalité de confiance, `rhythmDepthMax` ne devrait +/// gagner la loterie de surcharge qu'un axe sur ~14 (~7 %), pas 300/300 — +/// contrairement au scénario `_profile` ci-dessus où depth est délibérément +/// avantagé (sr=0.80 contre 0.50 pour les 13 autres, un écart plus grand +/// que le tie-break aléatoire ±0.05, donc une victoire garantie). +CapabilityProfile _profileTied(double sr) { + return CapabilityProfile({ + CapabilityAxis.rhythmDepthMax: CapabilityAxisState( + best: Position.throat.index.toDouble(), + comfort: Position.mid.index.toDouble(), + successRate: sr, + ), + CapabilityAxis.gorgeApneeStreak: _s(24, sr: sr), + CapabilityAxis.gorgeEngagementStreak: _s(35, sr: sr), + CapabilityAxis.gorgeCrossingsBpmThroat: _s(90, sr: sr), + CapabilityAxis.gorgeCrossingsBpmFull: _s(80, sr: sr), + CapabilityAxis.rhythmBpmCeilShallow: _s(100, sr: sr), + CapabilityAxis.rhythmBpmCeilThroat: _s(90, sr: sr), + CapabilityAxis.rhythmBpmCeilFull: _s(80, sr: sr), + CapabilityAxis.rhythmMotionStreak: _s(200, sr: sr), + CapabilityAxis.holdThroatStreak: _s(30, sr: sr), + CapabilityAxis.holdFullStreak: _s(25, sr: sr), + CapabilityAxis.noswallowStreak: _s(120, sr: sr), + CapabilityAxis.biffleStreak: _s(30, sr: sr), + CapabilityAxis.biffleBpmMax: _s(90, sr: sr), + }); +} + +int _countRhythmThroatTied({ + required double sr, + required int seedCount, +}) { + final profile = _profileTied(sr); + var count = 0; + for (var seed = 0; seed < seedCount; seed++) { + final r = CareerSessionGenerator(seed: seed).generate( + level: 14, + bank: _bank(), + durationSeconds: 1500, + humiliationCareer: 100.0, + obedience: 100.0, + unlockedKeys: _throatProvenUnlocks, + capability: CapabilityInputs(profile: profile), + ); + final hit = r.session.steps.any((s) => + !s.isTextOnly && + s.mode == SessionMode.rhythm && + s.to != null && + s.to!.index >= Position.throat.index); + if (hit) count++; + } + return count; +} + +/// Sur [seedCount] graines, compte séparément les séances qui contiennent +/// au moins un step **rhythm** (resp. **hold**) dont `to >= throat`. +/// +/// Séparation cruciale : `pickHoldPosition` (position_pickers.dart:160) est +/// documenté **« pas de cap par maxDepthIndex »** — la position d'un hold +/// est gatée par les milestones (`fullHold`/`throatHold`/`holdMid`), pas +/// par `rhythm.depth_max`. Un comptage fondu rhythm+hold (comme le fait +/// `use_me_mode_test.dart` pour une autre question) confond deux +/// mécanismes différents et ne peut pas trancher CE constat. +({int rhythm, int hold}) _countReachingThroat({ + required double depthSuccessRate, + required bool intense, + required bool useMe, + required int seedCount, + required Set unlockedKeys, +}) { + final profile = _profile(depthSuccessRate); + var rhythmCount = 0; + var holdCount = 0; + for (var seed = 0; seed < seedCount; seed++) { + final r = CareerSessionGenerator(seed: seed).generate( + level: 14, + bank: _bank(), + durationSeconds: 1500, // format "moyenne" (25 min) + intense: intense, + useMe: useMe, + humiliationCareer: 100.0, + obedience: 100.0, + unlockedKeys: unlockedKeys, + capability: CapabilityInputs(profile: profile), + ); + var rhythmHit = false; + var holdHit = false; + for (final s in r.session.steps) { + if (s.isTextOnly) continue; + final to = s.to; + if (to == null || to.index < Position.throat.index) continue; + if (s.mode == SessionMode.rhythm) rhythmHit = true; + if (s.mode == SessionMode.hold) holdHit = true; + } + if (rhythmHit) rhythmCount++; + if (holdHit) holdCount++; + } + return (rhythm: rhythmCount, hold: holdCount); +} + +/// Milestones réalistes pour un profil `comfort=mid/best=throat` : elle a +/// prouvé jusqu'à throat, donc les unlocks jusqu'à throat sont acquis — +/// mais PAS `fullHold`/`fullPulse` (jamais prouvé full). Contraste avec +/// `_allUnlocks` (utilisé par `use_me_mode_test.dart` pour une autre +/// question, qui ouvre aussi le palier full). +final Set _throatProvenUnlocks = { + UnlockKey.holdMid, + UnlockKey.throatHold, + UnlockKey.throatPulse, +}; + +void main() { + const seedCount = 300; + + test('normal, successRate 0.50 (< 0.65) — jamais de throat en rhythm', () { + final n = _countReachingThroat( + depthSuccessRate: 0.50, + intense: false, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + ); + // ignore: avoid_print + print('[depth-probe] normal sr=0.50 (sous le seuil) : ' + 'rhythm=${n.rhythm}/$seedCount hold=${n.hold}/$seedCount'); + expect(n.rhythm, 0, + reason: 'sous kDepthCranGate, capabilityCapFor ne doit jamais ' + 'accorder le +1 cran, quel que soit l\'axe surchargé tiré'); + }); + + test('normal, successRate 0.80 (>= 0.65) — le +1 cran doit sortir', () { + final n = _countReachingThroat( + depthSuccessRate: 0.80, + intense: false, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + ); + // ignore: avoid_print + print('[depth-probe] normal sr=0.80 (au-dessus du seuil) : ' + 'rhythm=${n.rhythm}/$seedCount hold=${n.hold}/$seedCount'); + expect(n.rhythm, greaterThan(0), + reason: 'au-dessus du seuil, le +1 cran doit sortir au moins parfois ' + '(quand rhythmDepthMax est tiré comme axe surchargé) — sinon le ' + 'plafond de TIRAGE (maxDepthIndex, dérivé du comfort BRUT, ' + 'jamais boosté hors useMe) empêche `capabilityCapFor` de ' + 'jamais servir à quoi que ce soit pour rhythm'); + }); + + test( + 'normal, TOUS les axes à sr=0.70 à égalité — loterie ~1/14, pas ' + 'déterministe', () { + final n = _countRhythmThroatTied(sr: 0.70, seedCount: seedCount); + // ignore: avoid_print + print('[depth-probe] normal égalité sr=0.70 (14 axes) : ' + 'rhythm=$n/$seedCount (≈${(n / seedCount * 100).toStringAsFixed(1)}%' + ', attendu ≈100/14≈7%)'); + expect(n, greaterThan(0), + reason: 'la loterie doit désigner depth au ' + 'moins une fois sur 300 graines'); + expect(n, lessThan(seedCount ~/ 3), + reason: 'à égalité stricte entre 14 ' + 'candidats, depth ne doit pas gagner la majorité des séances — ' + 'sinon le tirage n\'est pas ce que pickOverloadAxis documente'); + }); + + test('Encore (intense=true, useMe=false), successRate 0.50 — cas litigieux', + () { + final n = _countReachingThroat( + depthSuccessRate: 0.50, + intense: true, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + ); + // ignore: avoid_print + print('[depth-probe] Encore sr=0.50 (sous le seuil) : ' + 'rhythm=${n.rhythm}/$seedCount hold=${n.hold}/$seedCount'); + // Pas d'expect() ici : c'est la valeur mesurée elle-même qui tranche + // entre « le rapport a raison (300) » et « Encore diverge d'Utilise-moi + // (0, faute de plafond de tirage relevé) » — voir le rapport final. + }); + + test('Encore (intense=true, useMe=false), successRate 0.80', () { + final n = _countReachingThroat( + depthSuccessRate: 0.80, + intense: true, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + ); + // ignore: avoid_print + print('[depth-probe] Encore sr=0.80 (au-dessus du seuil) : ' + 'rhythm=${n.rhythm}/$seedCount hold=${n.hold}/$seedCount'); + }); + + test('Utilise-moi (intense=true, useMe=true), successRate 0.50 — 300/300 ?', + () { + final n = _countReachingThroat( + depthSuccessRate: 0.50, + intense: true, + useMe: true, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + ); + // ignore: avoid_print + print('[depth-probe] Utilise-moi sr=0.50 (sous le seuil) : ' + 'rhythm=${n.rhythm}/$seedCount hold=${n.hold}/$seedCount'); + expect(n.rhythm, seedCount, + reason: 'useMe force maxDepthIndex=full au tirage, puis ' + 'capabilityCapFor(intense) autorise comfort+1=throat sans ' + 'gate — devrait sortir dans TOUTES les séances'); + }); +} From 107723e4f2cbd07921cc4817a9ace73a62133188 Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:42:18 +0200 Subject: [PATCH 2/6] =?UTF-8?q?fix(career):=20sonde=20la=20profondeur=20ry?= =?UTF-8?q?thm=C3=A9e=20vers=20le=20best=20prouv=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Réapplication sur le code d'aujourd'hui de l'intention de 411feba (`origin/feat/depth-comfort-probe`, 101 commits de retard) : la profondeur rythmée vise **un cran au-dessus du comfort, borné par le `best` prouvé**. Les deux fonctions ciblées sont identiques à ce qu'elles étaient au merge-base (39cbcaa) — vérifié : `git diff 39cbcaa..origin/develop` est vide sur les deux fichiers. Le raisonnement d'origine tient donc tel quel. Deux écarts assumés par rapport à 411feba : - garde `max(comfort, …)` dans `capabilityCapFor` : un profil persisté où `best < comfort` verrait sinon la « sonde » RABAISSER le cap. Le `best` est monotone en mémoire, mais il est persisté séparément du `comfort` et rechargeable depuis un import — le cas n'est pas impossible. - `maxDepthIndexForProfile` formulé en `best.round() > rounded ? rounded+1 : rounded` (équivalent au `min` d'origine sur des entiers, non abaissant par construction). Le clamp lit `comfort` en double (le decay produit des crans fractionnaires), d'où le `min(comfort + 1, best)` conservé là — le consommateur arrondit, et `comfort=2.7 / best=3.0` doit donner throat, pas full. --- .../career/services/career_level_gates.dart | 16 +++++-- .../generation/capability_clamps.dart | 44 ++++++++++++------- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/rhythm_coach/lib/career/services/career_level_gates.dart b/rhythm_coach/lib/career/services/career_level_gates.dart index 02f2ee5..d091c66 100644 --- a/rhythm_coach/lib/career/services/career_level_gates.dart +++ b/rhythm_coach/lib/career/services/career_level_gates.dart @@ -86,8 +86,9 @@ class CareerLevelGates { static int defaultMaxDepthIndex() => Position.full.index; /// Plafond de profondeur dérivé du `CapabilityProfile` (Phase 19.7). - /// Lit `rhythm.depth_max.comfort` (en cran, double), l'arrondit et le - /// borne dans `[mid, full]`. + /// Lit `rhythm.depth_max.comfort` (en cran, double), l'arrondit, ajoute + /// **un cran de sonde** tant que le `best` prouvé est plus profond, et + /// borne le tout dans `[mid, full]`. /// /// Plancher à `mid` (= idx 2) : les basics enseignent déjà la /// progression jusqu'à `mid` (cf. `intro_deeper_basics`, `intro_hold_mid`, @@ -107,7 +108,16 @@ class CareerLevelGates { if (profile == null) return defaultMaxDepthIndex(); final comfort = profile.comfortOf(CapabilityAxis.rhythmDepthMax); if (comfort == null) return defaultMaxDepthIndex(); + // Sonde vers le `best` prouvé : quand la profondeur déjà atteinte est plus + // profonde que la cible courante (compétence rabaissée par un tap-out + // imputé ou par le decay), on repropose **un cran** au-dessus du comfort. + // Sans ça le tirage reste borné au comfort, donc `reached` ne dépasse + // jamais `comfort`, donc le régulateur n'a jamais l'overshoot qui le ferait + // remonter : descente facile, remontée verrouillée. Jamais au-delà du + // `best` — on ne sonde que du territoire déjà prouvé. final rounded = comfort.round(); - return rounded.clamp(Position.mid.index, Position.full.index); + final best = profile.bestOf(CapabilityAxis.rhythmDepthMax) ?? comfort; + final probe = best.round() > rounded ? rounded + 1 : rounded; + return probe.clamp(Position.mid.index, Position.full.index); } } diff --git a/rhythm_coach/lib/career/services/generation/capability_clamps.dart b/rhythm_coach/lib/career/services/generation/capability_clamps.dart index 964c3a6..e63e393 100644 --- a/rhythm_coach/lib/career/services/generation/capability_clamps.dart +++ b/rhythm_coach/lib/career/services/generation/capability_clamps.dart @@ -168,8 +168,8 @@ class CapabilityClamps implements CapabilityClampSurface { static const double kIntenseComfortBoost = 1.30; /// Bonus de cran de profondeur appliqué à `rhythmDepthMax` quand - /// `intense=true` (séance d'escalade). Cumulé avec le +1 cran de - /// surcharge si l'axe est aussi `overloadAxis`. Le clamp `to <= + /// `intense=true` (séance d'escalade). Cumulé avec le +1 cran accordé en + /// amont par [capabilityCapFor] (surcharge ou sonde). Le clamp `to <= /// maxDepthIndex` (Position.full) et les milestones acquittées bornent /// en amont via `_maxDepthIndex`/`_milestoneRhythmCeilingIdx`, donc /// "+1 cran" ne fait jamais sauter une milestone non débloquée. @@ -177,7 +177,8 @@ class CapabilityClamps implements CapabilityClampSurface { /// Plafond effectif (= le plus contraignant) d'un axe de capacité pour /// la génération en cours : minimum de `comfort` (éventuellement - /// **surchargé** si c'est [overloadAxis] de la séance, et **boosté** + /// **surchargé** si c'est [overloadAxis] de la séance, **sondé d'un cran + /// vers le `best` prouvé** pour `rhythmDepthMax`, et **boosté** /// par `kIntenseComfortBoost` si la séance est `intense`) et du plafond /// figé sur un FAIL de cette session ([ceilings], §6 — qui plafonne /// *même* l'axe surchargé : pas de re-fail dans la même séance). @@ -189,18 +190,31 @@ class CapabilityClamps implements CapabilityClampSurface { final p = config.capProfile; if (p == null) return null; var comfort = p.comfortOf(axis); - if (comfort != null && axis == config.overloadAxis) { - if (axis == CapabilityAxis.rhythmDepthMax) { - // Profondeur = cran discret : on autorise +1 cran, et seulement si - // la confiance au cran courant est là (cf. asymétries §5). - // « Humiliation l'autorise » + « milestone d'unlock acquittée » - // sont déjà garantis par `_maxDepthIndex` (qui borne `to` en amont). - if (p.stateOf(axis).successRate >= CapabilityRegulator.kDepthCranGate) { - comfort = comfort + 1; - } - } else { - comfort = comfort * config.overloadFactor; - } + if (comfort != null && axis == CapabilityAxis.rhythmDepthMax) { + // Profondeur = cran discret, deux régimes distincts pour le +1 cran : + // + // - **Escalade** (la profondeur EST l'axe surchargé et la confiance au + // cran courant est là) : le cran peut dépasser le `best`. C'est ce + // qui fait grandir le `best` — comportement d'origine, inchangé. + // - **Sonde** (tout le reste) : le cran est borné par le `best` déjà + // prouvé. On ne pousse pas au-delà de ce qu'elle a tenu, donc pas + // besoin d'attendre que l'axe gagne la loterie de surcharge ni que + // `successRate` repasse le seuil — deux conditions qui verrouillaient + // la remontée : tant que le clamp rabat `to` au comfort, `reached` ne + // dépasse jamais le comfort, donc le régulateur ne voit jamais + // l'overshoot qui le ferait remonter. + // + // `max(comfort, …)` : garde-fou pour un profil où `best < comfort` + // (état persisté incohérent) — la sonde ne doit jamais RABAISSER le cap. + // « Milestone d'unlock acquittée » reste garanti en amont par + // `_maxDepthIndex` / `milestoneRhythmCeilingIdx`. + final best = p.bestOf(axis) ?? comfort; + final escalating = axis == config.overloadAxis && + p.stateOf(axis).successRate >= CapabilityRegulator.kDepthCranGate; + comfort = + escalating ? comfort + 1 : max(comfort, min(comfort + 1, best)); + } else if (comfort != null && axis == config.overloadAxis) { + comfort = comfort * config.overloadFactor; } // Boost intense (Supplier / Encore) : cumulatif avec la surcharge // de l'axe. Pour `rhythmDepthMax` (cran discret), bonus exprimé en From 7836a3cf4ebc24c1140a57ff32f5fcfcb3cf824a Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:49:05 +0200 Subject: [PATCH 3/6] test(career): retourne la sonde profondeur vers le comportement voulu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux `expect` figeaient le défaut ; ils expriment maintenant la règle voulue. Ajout des cas qui bornent le correctif — c'est là que se joue « est-ce trop généreux ? » : un cran seulement (comfort=head/best=throat reste borné à mid, 0/300), no-op strict quand best == comfort (0/300), pas d'abaissement quand best < comfort, et le tableau unitaire de `maxDepthIndexForProfile`. Non-régression des tenues : deux invariants insensibles à la dérive RNG (les compteurs bruts ne sont PAS comparables step à step — déplacer un step rythmé fait diverger toute la séquence en aval) : la tranche throat sort toujours en tenue, et `full` reste fermé sans `fullHold` acquise. Discrimination vérifiée sur un worktree détaché sur origin/develop : - avec le correctif : 11/11 verts - sur origin/develop : 2 rouges (rhythm 0 au lieu de 300 ; maxDepthIndex 2 au lieu de 3), les 9 autres verts — ce sont les non-régressions. --- ...capability_depth_comfort_vs_best_test.dart | 341 +++++++++--------- 1 file changed, 168 insertions(+), 173 deletions(-) diff --git a/rhythm_coach/test/capability_depth_comfort_vs_best_test.dart b/rhythm_coach/test/capability_depth_comfort_vs_best_test.dart index 53cb1cc..847b297 100644 --- a/rhythm_coach/test/capability_depth_comfort_vs_best_test.dart +++ b/rhythm_coach/test/capability_depth_comfort_vs_best_test.dart @@ -1,23 +1,26 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:beat_bitch/career/models/career_generation_inputs.dart'; import 'package:beat_bitch/career/models/phrase_bank.dart'; +import 'package:beat_bitch/career/services/career_level_gates.dart'; import 'package:beat_bitch/career/services/generation/career_session_generator.dart'; import 'package:beat_bitch/services/capability_service.dart'; -/// Sonde adversariale — rejeu du constat « grave » du tri du 2026-08-18 -/// (verrou de profondeur : le générateur cible `comfort`, pas `best`). +/// Verrou de profondeur — le `comfort` rabaissé sous le `best` prouvé ne doit +/// pas faire disparaître la tranche throat/full des séances normales. /// -/// Prédiction écrite avant exécution : avec `comfort=mid`/`best=throat`, -/// une séance normale ne devrait produire un step visant `throat` que si -/// `rhythmDepthMax` est l'axe surchargé de la séance ET que sa -/// `successRate` franchit `kDepthCranGate` (0.65) — jamais en dessous. -/// En séance `intense` (Encore) le bonus de cran est inconditionnel dans -/// `capabilityCapFor`, mais `maxDepthIndex` (plafond de *tirage*, distinct -/// du clamp) reste dérivé du `comfort` brut pour Encore (`useMe=false`) — -/// contrairement à Utilise-moi qui le force à `full`. Hypothèse à -/// trancher ici : Encore seul peut donc ne JAMAIS tirer `throat`, même si -/// `capabilityCapFor` l'autoriserait — un désaccord avec le rapport, qui -/// annonce 300/300 pour « Utilise-moi ou Encore » sans distinguer les deux. +/// Origine : sonde adversariale du tri du 2026-08-18, reprise telle quelle +/// puis retournée. Dans sa version d'origine elle *caractérisait* le défaut +/// (elle attendait `rhythm == 0` en séance normale sous le seuil, et passait +/// au vert sur `origin/develop`). Les deux attentes qui figeaient le défaut +/// sont réécrites ici pour exprimer le comportement voulu — les deux mesures +/// brutes correspondantes sont conservées telles quelles dans le rapport. +/// +/// Ce que le correctif fait, tel que mesuré : la profondeur rythmée vise **un +/// cran** au-dessus du `comfort`, borné par le `best`. Donc : +/// - `comfort=mid` / `best=throat` → cap throat : la tranche revient ; +/// - `comfort=head` / `best=throat` → cap mid : un cran, pas le retour à +/// throat — la remontée reste graduelle ; +/// - `best == comfort` → no-op strict. List _p(List texts) => texts.map((t) => PhraseEntry(text: t)).toList(); @@ -44,11 +47,15 @@ CapabilityAxisState _s(double v, {double sr = 0.5}) => /// une joueuse avec un historique), pour que `rhythmDepthMax` entre en /// concurrence pour le tirage d'axe surchargé au lieu d'être le seul /// candidat (ce qui gonflerait artificiellement son taux de sélection). -CapabilityProfile _profile(double depthSuccessRate) { +CapabilityProfile _profile( + double depthSuccessRate, { + double comfort = 2.0, // mid + double best = 3.0, // throat +}) { return CapabilityProfile({ CapabilityAxis.rhythmDepthMax: CapabilityAxisState( - best: Position.throat.index.toDouble(), - comfort: Position.mid.index.toDouble(), + best: best, + comfort: comfort, successRate: depthSuccessRate, ), CapabilityAxis.gorgeApneeStreak: _s(24), @@ -67,81 +74,28 @@ CapabilityProfile _profile(double depthSuccessRate) { }); } -/// Variante « à égalité » : les 14 axes surchargeables partagent la MÊME -/// `successRate` (dont `rhythmDepthMax`). Isole le tirage lui-même : si -/// tous les axes sont à égalité de confiance, `rhythmDepthMax` ne devrait -/// gagner la loterie de surcharge qu'un axe sur ~14 (~7 %), pas 300/300 — -/// contrairement au scénario `_profile` ci-dessus où depth est délibérément -/// avantagé (sr=0.80 contre 0.50 pour les 13 autres, un écart plus grand -/// que le tie-break aléatoire ±0.05, donc une victoire garantie). -CapabilityProfile _profileTied(double sr) { - return CapabilityProfile({ - CapabilityAxis.rhythmDepthMax: CapabilityAxisState( - best: Position.throat.index.toDouble(), - comfort: Position.mid.index.toDouble(), - successRate: sr, - ), - CapabilityAxis.gorgeApneeStreak: _s(24, sr: sr), - CapabilityAxis.gorgeEngagementStreak: _s(35, sr: sr), - CapabilityAxis.gorgeCrossingsBpmThroat: _s(90, sr: sr), - CapabilityAxis.gorgeCrossingsBpmFull: _s(80, sr: sr), - CapabilityAxis.rhythmBpmCeilShallow: _s(100, sr: sr), - CapabilityAxis.rhythmBpmCeilThroat: _s(90, sr: sr), - CapabilityAxis.rhythmBpmCeilFull: _s(80, sr: sr), - CapabilityAxis.rhythmMotionStreak: _s(200, sr: sr), - CapabilityAxis.holdThroatStreak: _s(30, sr: sr), - CapabilityAxis.holdFullStreak: _s(25, sr: sr), - CapabilityAxis.noswallowStreak: _s(120, sr: sr), - CapabilityAxis.biffleStreak: _s(30, sr: sr), - CapabilityAxis.biffleBpmMax: _s(90, sr: sr), - }); -} - -int _countRhythmThroatTied({ - required double sr, - required int seedCount, -}) { - final profile = _profileTied(sr); - var count = 0; - for (var seed = 0; seed < seedCount; seed++) { - final r = CareerSessionGenerator(seed: seed).generate( - level: 14, - bank: _bank(), - durationSeconds: 1500, - humiliationCareer: 100.0, - obedience: 100.0, - unlockedKeys: _throatProvenUnlocks, - capability: CapabilityInputs(profile: profile), - ); - final hit = r.session.steps.any((s) => - !s.isTextOnly && - s.mode == SessionMode.rhythm && - s.to != null && - s.to!.index >= Position.throat.index); - if (hit) count++; - } - return count; -} - /// Sur [seedCount] graines, compte séparément les séances qui contiennent -/// au moins un step **rhythm** (resp. **hold**) dont `to >= throat`. +/// au moins un step **rhythm** (resp. **hold**) dont `to >= throat`, et la +/// profondeur maximale jamais atteinte par une **tenue**. /// /// Séparation cruciale : `pickHoldPosition` (position_pickers.dart:160) est /// documenté **« pas de cap par maxDepthIndex »** — la position d'un hold /// est gatée par les milestones (`fullHold`/`throatHold`/`holdMid`), pas -/// par `rhythm.depth_max`. Un comptage fondu rhythm+hold (comme le fait -/// `use_me_mode_test.dart` pour une autre question) confond deux +/// par `rhythm.depth_max`. Un comptage fondu rhythm+hold confond deux /// mécanismes différents et ne peut pas trancher CE constat. -({int rhythm, int hold}) _countReachingThroat({ +({int rhythm, int hold, Position? deepestHold}) _countReachingThroat({ required double depthSuccessRate, required bool intense, required bool useMe, required int seedCount, required Set unlockedKeys, + double comfort = 2.0, + double best = 3.0, }) { - final profile = _profile(depthSuccessRate); + final profile = _profile(depthSuccessRate, comfort: comfort, best: best); var rhythmCount = 0; var holdCount = 0; + Position? deepestHold; for (var seed = 0; seed < seedCount; seed++) { final r = CareerSessionGenerator(seed: seed).generate( level: 14, @@ -159,21 +113,24 @@ int _countRhythmThroatTied({ for (final s in r.session.steps) { if (s.isTextOnly) continue; final to = s.to; - if (to == null || to.index < Position.throat.index) continue; + if (to == null) continue; + if (s.mode == SessionMode.hold && + (deepestHold == null || to.index > deepestHold.index)) { + deepestHold = to; + } + if (to.index < Position.throat.index) continue; if (s.mode == SessionMode.rhythm) rhythmHit = true; if (s.mode == SessionMode.hold) holdHit = true; } if (rhythmHit) rhythmCount++; if (holdHit) holdCount++; } - return (rhythm: rhythmCount, hold: holdCount); + return (rhythm: rhythmCount, hold: holdCount, deepestHold: deepestHold); } /// Milestones réalistes pour un profil `comfort=mid/best=throat` : elle a /// prouvé jusqu'à throat, donc les unlocks jusqu'à throat sont acquis — -/// mais PAS `fullHold`/`fullPulse` (jamais prouvé full). Contraste avec -/// `_allUnlocks` (utilisé par `use_me_mode_test.dart` pour une autre -/// question, qui ouvre aussi le palier full). +/// mais PAS `fullHold`/`fullPulse` (jamais prouvé full). final Set _throatProvenUnlocks = { UnlockKey.holdMid, UnlockKey.throatHold, @@ -183,103 +140,141 @@ final Set _throatProvenUnlocks = { void main() { const seedCount = 300; - test('normal, successRate 0.50 (< 0.65) — jamais de throat en rhythm', () { - final n = _countReachingThroat( - depthSuccessRate: 0.50, - intense: false, - useMe: false, - seedCount: seedCount, - unlockedKeys: _throatProvenUnlocks, - ); - // ignore: avoid_print - print('[depth-probe] normal sr=0.50 (sous le seuil) : ' - 'rhythm=${n.rhythm}/$seedCount hold=${n.hold}/$seedCount'); - expect(n.rhythm, 0, - reason: 'sous kDepthCranGate, capabilityCapFor ne doit jamais ' - 'accorder le +1 cran, quel que soit l\'axe surchargé tiré'); - }); + group('sonde vers le best prouvé', () { + test('comfort=mid/best=throat, sr sous le seuil — throat revient', () { + final n = _countReachingThroat( + depthSuccessRate: 0.50, + intense: false, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + ); + expect(n.rhythm, seedCount, + reason: 'la sonde vise le best PROUVÉ : reproposer throat ne pousse ' + 'pas au-delà de ce qu\'elle a tenu, donc elle ne doit dépendre ' + 'ni de la loterie de surcharge ni de kDepthCranGate — sinon ' + 'aucun overshoot n\'est possible et le comfort ne remonte ' + 'jamais (le verrou d\'origine : 0/300 ici)'); + }); - test('normal, successRate 0.80 (>= 0.65) — le +1 cran doit sortir', () { - final n = _countReachingThroat( - depthSuccessRate: 0.80, - intense: false, - useMe: false, - seedCount: seedCount, - unlockedKeys: _throatProvenUnlocks, - ); - // ignore: avoid_print - print('[depth-probe] normal sr=0.80 (au-dessus du seuil) : ' - 'rhythm=${n.rhythm}/$seedCount hold=${n.hold}/$seedCount'); - expect(n.rhythm, greaterThan(0), - reason: 'au-dessus du seuil, le +1 cran doit sortir au moins parfois ' - '(quand rhythmDepthMax est tiré comme axe surchargé) — sinon le ' - 'plafond de TIRAGE (maxDepthIndex, dérivé du comfort BRUT, ' - 'jamais boosté hors useMe) empêche `capabilityCapFor` de ' - 'jamais servir à quoi que ce soit pour rhythm'); - }); + test('comfort=mid/best=throat, sr au-dessus du seuil — inchangé', () { + final n = _countReachingThroat( + depthSuccessRate: 0.80, + intense: false, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + ); + expect(n.rhythm, seedCount); + }); - test( - 'normal, TOUS les axes à sr=0.70 à égalité — loterie ~1/14, pas ' - 'déterministe', () { - final n = _countRhythmThroatTied(sr: 0.70, seedCount: seedCount); - // ignore: avoid_print - print('[depth-probe] normal égalité sr=0.70 (14 axes) : ' - 'rhythm=$n/$seedCount (≈${(n / seedCount * 100).toStringAsFixed(1)}%' - ', attendu ≈100/14≈7%)'); - expect(n, greaterThan(0), - reason: 'la loterie doit désigner depth au ' - 'moins une fois sur 300 graines'); - expect(n, lessThan(seedCount ~/ 3), - reason: 'à égalité stricte entre 14 ' - 'candidats, depth ne doit pas gagner la majorité des séances — ' - 'sinon le tirage n\'est pas ce que pickOverloadAxis documente'); - }); + test('la sonde rend UN cran, pas le best : comfort=head reste borné à mid', + () { + final n = _countReachingThroat( + depthSuccessRate: 0.80, + intense: false, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + comfort: Position.head.index.toDouble(), + ); + expect(n.rhythm, 0, + reason: 'comfort=head + 1 cran = mid : une chute de deux crans ne ' + 'se rattrape pas d\'un coup, même best=throat prouvé'); + }); - test('Encore (intense=true, useMe=false), successRate 0.50 — cas litigieux', - () { - final n = _countReachingThroat( - depthSuccessRate: 0.50, - intense: true, - useMe: false, - seedCount: seedCount, - unlockedKeys: _throatProvenUnlocks, - ); - // ignore: avoid_print - print('[depth-probe] Encore sr=0.50 (sous le seuil) : ' - 'rhythm=${n.rhythm}/$seedCount hold=${n.hold}/$seedCount'); - // Pas d'expect() ici : c'est la valeur mesurée elle-même qui tranche - // entre « le rapport a raison (300) » et « Encore diverge d'Utilise-moi - // (0, faute de plafond de tirage relevé) » — voir le rapport final. + test('best == comfort — no-op strict', () { + final n = _countReachingThroat( + depthSuccessRate: 0.50, + intense: false, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + best: Position.mid.index.toDouble(), + ); + expect(n.rhythm, 0, + reason: 'axe consolidé : rien à sonder, comportement Phase 19.7'); + }); + + test('best < comfort (profil incohérent) — la sonde n\'abaisse pas', () { + final n = _countReachingThroat( + depthSuccessRate: 0.50, + intense: false, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + comfort: Position.throat.index.toDouble(), + best: Position.mid.index.toDouble(), + ); + expect(n.rhythm, seedCount, + reason: 'un état persisté où best < comfort ne doit pas faire ' + 'RABAISSER le cap sous le comfort'); + }); + + test('maxDepthIndexForProfile suit la même règle', () { + int cap(double comfort, double best) => + CareerLevelGates.maxDepthIndexForProfile(_profile(0.5, + comfort: comfort, best: best)); + expect(cap(2, 3), Position.throat.index); // mid + 1 cran, borné au best + expect(cap(2, 4), Position.throat.index); // un seul cran, pas full + expect(cap(2, 2), Position.mid.index); // consolidé : no-op + expect(cap(1, 3), Position.mid.index); // head + 1 cran, planché mid + expect(cap(3, 2), Position.throat.index); // incohérent : pas d'abaissement + expect(cap(4, 4), Position.full.index); // plafond full conservé + }); }); - test('Encore (intense=true, useMe=false), successRate 0.80', () { - final n = _countReachingThroat( - depthSuccessRate: 0.80, - intense: true, - useMe: false, - seedCount: seedCount, - unlockedKeys: _throatProvenUnlocks, - ); - // ignore: avoid_print - print('[depth-probe] Encore sr=0.80 (au-dessus du seuil) : ' - 'rhythm=${n.rhythm}/$seedCount hold=${n.hold}/$seedCount'); + group('escalade — inchangée', () { + test('Encore (intense, sans surcharge ni seuil)', () { + final n = _countReachingThroat( + depthSuccessRate: 0.50, + intense: true, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + ); + expect(n.rhythm, seedCount); + }); + + test('Utilise-moi', () { + final n = _countReachingThroat( + depthSuccessRate: 0.50, + intense: true, + useMe: true, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + ); + expect(n.rhythm, seedCount); + }); }); - test('Utilise-moi (intense=true, useMe=true), successRate 0.50 — 300/300 ?', - () { - final n = _countReachingThroat( - depthSuccessRate: 0.50, - intense: true, - useMe: true, - seedCount: seedCount, - unlockedKeys: _throatProvenUnlocks, - ); - // ignore: avoid_print - print('[depth-probe] Utilise-moi sr=0.50 (sous le seuil) : ' - 'rhythm=${n.rhythm}/$seedCount hold=${n.hold}/$seedCount'); - expect(n.rhythm, seedCount, - reason: 'useMe force maxDepthIndex=full au tirage, puis ' - 'capabilityCapFor(intense) autorise comfort+1=throat sans ' - 'gate — devrait sortir dans TOUTES les séances'); + group('non-régression des tenues', () { + // Les tenues sont gatées par les milestones hold, pas par + // `rhythm.depth_max` : le correctif ne doit rien y changer. Les compteurs + // bruts ne sont pas comparables step à step (déplacer un step rythmé fait + // diverger toute la séquence RNG en aval) — d'où deux invariants qui, eux, + // le sont : la tranche throat sort toujours, et `full` reste fermé. + for (final (label, comfort, best) in [ + ('sonde active (mid/throat)', 2.0, 3.0), + ('no-op (mid/mid)', 2.0, 2.0), + ('chute de deux crans (head/throat)', 1.0, 3.0), + ]) { + test('$label — tenues throat présentes, full jamais ouvert', () { + final n = _countReachingThroat( + depthSuccessRate: 0.50, + intense: false, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + comfort: comfort, + best: best, + ); + expect(n.hold, seedCount, + reason: 'les tenues throat ne dépendent pas du profil rythme'); + expect(n.deepestHold, Position.throat, + reason: 'sans `fullHold` acquise, aucune tenue ne doit atteindre ' + 'full — le correctif ne doit pas ouvrir de palier de tenue'); + }); + } }); } From 2e62312a362662afe00473a8d451394ae636123f Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:52:52 +0200 Subject: [PATCH 4/6] =?UTF-8?q?style:=20dart=20format=20sur=20les=20fichie?= =?UTF-8?q?rs=20touch=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/career/services/generation/capability_clamps.dart | 3 +-- .../test/capability_depth_comfort_vs_best_test.dart | 7 ++++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/rhythm_coach/lib/career/services/generation/capability_clamps.dart b/rhythm_coach/lib/career/services/generation/capability_clamps.dart index e63e393..e563ff0 100644 --- a/rhythm_coach/lib/career/services/generation/capability_clamps.dart +++ b/rhythm_coach/lib/career/services/generation/capability_clamps.dart @@ -211,8 +211,7 @@ class CapabilityClamps implements CapabilityClampSurface { final best = p.bestOf(axis) ?? comfort; final escalating = axis == config.overloadAxis && p.stateOf(axis).successRate >= CapabilityRegulator.kDepthCranGate; - comfort = - escalating ? comfort + 1 : max(comfort, min(comfort + 1, best)); + comfort = escalating ? comfort + 1 : max(comfort, min(comfort + 1, best)); } else if (comfort != null && axis == config.overloadAxis) { comfort = comfort * config.overloadFactor; } diff --git a/rhythm_coach/test/capability_depth_comfort_vs_best_test.dart b/rhythm_coach/test/capability_depth_comfort_vs_best_test.dart index 847b297..16f94dd 100644 --- a/rhythm_coach/test/capability_depth_comfort_vs_best_test.dart +++ b/rhythm_coach/test/capability_depth_comfort_vs_best_test.dart @@ -213,13 +213,14 @@ void main() { test('maxDepthIndexForProfile suit la même règle', () { int cap(double comfort, double best) => - CareerLevelGates.maxDepthIndexForProfile(_profile(0.5, - comfort: comfort, best: best)); + CareerLevelGates.maxDepthIndexForProfile( + _profile(0.5, comfort: comfort, best: best)); expect(cap(2, 3), Position.throat.index); // mid + 1 cran, borné au best expect(cap(2, 4), Position.throat.index); // un seul cran, pas full expect(cap(2, 2), Position.mid.index); // consolidé : no-op expect(cap(1, 3), Position.mid.index); // head + 1 cran, planché mid - expect(cap(3, 2), Position.throat.index); // incohérent : pas d'abaissement + expect( + cap(3, 2), Position.throat.index); // incohérent : pas d'abaissement expect(cap(4, 4), Position.full.index); // plafond full conservé }); }); From 438e73932554f19113b0b30673c1cc8c2a39e717 Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:59:24 +0200 Subject: [PATCH 5/6] =?UTF-8?q?test(career):=20simule=20la=20boucle=20s?= =?UTF-8?q?=C3=A9ance=20=E2=86=92=20r=C3=A9gulateur=20=E2=86=92=20s=C3=A9a?= =?UTF-8?q?nce=20suivante?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les sondes de profondeur existantes injectent un profil figé dans le générateur : elles prouvent que la tranche throat redevient visible, pas que le `comfort` remonte. Ce harnais referme le circuit — profil persisté relu par le générateur, séance rejouée dans le vrai `CapabilityTracker`, rapport repassé par le vrai `CapabilityService.commit`. Mesuré sur la branche : comfort=mid/best=throat remonte à throat à la 2ᵉ séance (5 bases de graines), une chute de deux crans en 3, un tap-out coûte un cran et la profondeur proposée redescend avec lui. Les 4 tests sont rouges sur origin/develop, où le comfort ne bouge pas de 20 séances. --- .../capability_depth_comfort_loop_test.dart | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 rhythm_coach/test/capability_depth_comfort_loop_test.dart diff --git a/rhythm_coach/test/capability_depth_comfort_loop_test.dart b/rhythm_coach/test/capability_depth_comfort_loop_test.dart new file mode 100644 index 0000000..e515977 --- /dev/null +++ b/rhythm_coach/test/capability_depth_comfort_loop_test.dart @@ -0,0 +1,302 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:beat_bitch/career/models/career_generation_inputs.dart'; +import 'package:beat_bitch/career/models/phrase_bank.dart'; +import 'package:beat_bitch/career/services/generation/career_session_generator.dart'; +import 'package:beat_bitch/services/capability_service.dart'; +import 'package:beat_bitch/services/capability_tracker.dart'; +import 'package:beat_bitch/services/saliva_engine.dart'; + +/// Boucle complète séance → régulateur → séance suivante, sur la profondeur +/// rythmée. Les autres sondes de profondeur injectent un profil **figé** dans +/// le générateur : elles montrent que la tranche throat redevient visible, +/// jamais que le `comfort` remonte. Celle-ci referme le circuit — le profil +/// persisté est relu par le générateur, la séance produite est rejouée dans le +/// vrai `CapabilityTracker`, et son rapport repasse par le vrai +/// `CapabilityService.commit` avant la séance suivante. +/// +/// Simplifications assumées du rejeu (le `SessionController` n'est pas +/// instancié) : +/// - les `chainAction` ne sont pas déroulées ; +/// - après un tap-out la séance s'arrête, là où l'app régénère la suite bornée +/// par les `sessionCeilings` ; +/// - le temps avance d'une seconde exacte par tick, sans TTS ni différé. + +List _p(List texts) => + texts.map((t) => PhraseEntry(text: t)).toList(); + +PhraseBank _bank() => PhraseBank( + byMode: { + for (final m in SessionMode.values) + m: { + 'soft': _p(['s']), + 'medium': _p(['m']), + 'hard': _p(['h']), + 'boost': _p(['b']), + 'finale': _p(['f']), + }, + }, + congrats: _p(['bravo']), + intros: _p(['intro']), + ); + +/// Milestones d'une joueuse qui a prouvé jusqu'à throat, jamais full. +final Set _throatProvenUnlocks = { + UnlockKey.holdMid, + UnlockKey.throatHold, + UnlockKey.throatPulse, +}; + +/// Les 14 axes surchargeables renseignés, comme un profil d'historique — pour +/// que `rhythmDepthMax` entre en concurrence pour le tirage d'axe surchargé. +const Map _otherAxes = { + 'gorge.apnee.streak': 24, + 'gorge.engagement.streak': 35, + 'gorge.crossings.bpm.throat': 90, + 'gorge.crossings.bpm.full': 80, + 'rhythm.bpm_ceil.shallow': 100, + 'rhythm.bpm_ceil.throat': 90, + 'rhythm.bpm_ceil.full': 80, + 'rhythm.motion.streak': 200, + 'hold.throat.streak': 30, + 'hold.full.streak': 25, + 'noswallow.streak': 120, + 'biffle.streak': 30, + 'biffle.bpm_max': 90, +}; + +/// Profil persisté de départ : `comfort` rabaissé sous le `best` prouvé, et +/// une confiance sous `kDepthCranGate` — l'état exact du défaut. +Map _seed({ + required double comfort, + required double best, + double successRate = 0.50, +}) { + final values = {}; + void put(String key, double best, double comfort, double sr) { + values['cap.$key.best'] = best; + values['cap.$key.comfort'] = comfort; + values['cap.$key.sr'] = sr; + values['cap.$key.seen'] = 0; + } + + put(CapabilityAxis.rhythmDepthMax.storageKey, best, comfort, successRate); + _otherAxes.forEach((key, v) => put(key, v, v, 0.5)); + return values; +} + +/// Ce qu'une séance a produit une fois rejouée. +typedef _Played = ({ + SessionCapabilityReport report, + int deepestRhythm, + bool tappedOut, +}); + +/// Génère une séance depuis [profile] et la rejoue seconde par seconde dans un +/// `CapabilityTracker`, comme le fait `SessionController`. +/// +/// [tapOut] : la joueuse tape out dès qu'un step rythmé vise plus profond que +/// son `comfort` — elle ne tient pas le cran sondé. Le tap-out tombe 2 s après +/// le step, donc avant le seuil de record soutenu (3 s) : elle ne pose aucun +/// record à ce cran, seulement un plafond de session. +_Played _play({ + required CapabilityProfile profile, + required int seed, + required bool tapOut, +}) { + final result = CareerSessionGenerator(seed: seed).generate( + level: 14, + bank: _bank(), + durationSeconds: 1500, + humiliationCareer: 100.0, + obedience: 100.0, + unlockedKeys: _throatProvenUnlocks, + capability: CapabilityInputs(profile: profile), + ); + final session = result.session; + final comfort = profile.comfortOf(CapabilityAxis.rhythmDepthMax) ?? 0; + + final tracker = CapabilityTracker()..onSessionStart(); + final byTime = >{}; + var lastTime = 0; + for (final step in session.steps) { + byTime.putIfAbsent(step.time, () => []).add(step); + if (step.time > lastTime) lastTime = step.time; + } + final totalSeconds = + session.durationSeconds > lastTime ? session.durationSeconds : lastTime; + + var swallow = SwallowMode.allowed; + var deepestRhythm = 0; + var tappedOut = false; + var failAt = -1; + + for (var t = 0; t <= totalSeconds; t++) { + for (final step in byTime[t] ?? const []) { + final stepSwallow = step.swallowMode; + if (stepSwallow != null) swallow = stepSwallow; + if (step.isTextOnly) continue; + final mode = step.mode ?? session.defaultMode; + tracker.onStepApplied( + mode: mode, + from: step.from, + to: step.to, + bpm: step.bpm, + duration: step.duration, + ); + final to = step.to; + if (mode == SessionMode.rhythm && to != null) { + if (to.index > deepestRhythm) deepestRhythm = to.index; + if (tapOut && failAt < 0 && to.index > comfort) { + failAt = t + 2; + } + } + } + tracker.onTickSecond(swallowMode: swallow); + if (failAt >= 0 && t >= failAt) { + tracker.onFail(); + tappedOut = true; + break; + } + } + + return ( + report: tracker.finalizeReport(), + deepestRhythm: deepestRhythm, + tappedOut: tappedOut, + ); +} + +/// Une séance de la boucle, telle qu'observée de l'extérieur : le `comfort` +/// qui a servi à la générer, la profondeur rythmée qu'elle a effectivement +/// proposée, et l'état du profil une fois le rapport régulé. +typedef _Round = ({ + double comfortBefore, + double comfort, + double best, + double successRate, + int deepestRhythm, + bool tappedOut, +}); + +/// Enchaîne [sessions] séances en repassant chaque rapport au régulateur. +/// Renvoie l'état du profil **après** chaque séance. +Future> _loop({ + required double comfort, + required double best, + required int sessions, + Set tapOutOn = const {}, + int seedBase = 0, +}) async { + SharedPreferences.setMockInitialValues(_seed(comfort: comfort, best: best)); + final service = CapabilityService(); + final rounds = <_Round>[]; + + for (var i = 1; i <= sessions; i++) { + final profile = await service.snapshotProfile(); + final played = _play( + profile: profile, + seed: seedBase + i, + tapOut: tapOutOn.contains(i), + ); + await service.commit(played.report, sessionIndex: i); + final after = await service.snapshotProfile(); + final state = after.stateOf(CapabilityAxis.rhythmDepthMax); + rounds.add(( + comfortBefore: profile.comfortOf(CapabilityAxis.rhythmDepthMax) ?? 0, + comfort: state.comfort ?? 0, + best: state.best ?? 0, + successRate: state.successRate, + deepestRhythm: played.deepestRhythm, + tappedOut: played.tappedOut, + )); + } + return rounds; +} + +const double _head = 1; +const double _mid = 2; +const double _throat = 3; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('boucle complète — le comfort remonte vers le best', () { + test('comfort=mid / best=throat : throat récupéré à la 2ᵉ séance', + () async { + for (final seedBase in [0, 100, 200, 300, 400]) { + final rounds = await _loop( + comfort: _mid, + best: _throat, + sessions: 6, + seedBase: seedBase, + ); + expect(rounds[0].deepestRhythm, Position.throat.index, + reason: 'graines $seedBase : la sonde rend la tranche throat ' + 'visible dès la 1ʳᵉ séance — c\'est la condition nécessaire, ' + 'sur origin/develop la profondeur proposée plafonne à mid'); + expect(rounds[0].comfort, _mid, + reason: 'séance 1 : l\'overshoot est vu, mais le ratchet ↑ d\'un ' + 'axe depthCran est en plus gaté par kDepthCranGate — la ' + 'confiance (0,50) n\'y est pas encore'); + expect(rounds[0].successRate, + closeTo(CapabilityRegulator.kDepthCranGate, 1e-9), + reason: 'l\'EMA de succès passe 0,50 → 0,65 = le seuil, tout ' + 'juste : c\'est ce qui coûte la 1ʳᵉ séance'); + expect(rounds[1].comfort, _throat, + reason: 'séance 2 : le seuil est atteint, le cran est rendu'); + for (final r in rounds.skip(1)) { + expect(r.comfort, _throat, reason: 'et il ne rebouge plus'); + expect(r.best, _throat, + reason: 'la sonde ne pousse jamais au-delà du territoire prouvé'); + } + } + }); + + test('chute de deux crans : un cran par séance, throat à la 3ᵉ', () async { + final rounds = await _loop(comfort: _head, best: _throat, sessions: 6); + expect( + rounds.map((r) => r.comfort).take(3).toList(), [_head, _mid, _throat], + reason: 'la remontée reste graduelle — jamais de retour direct au ' + 'best (sur origin/develop elle s\'arrête à mid, plancher de ' + 'maxDepthIndexForProfile, et n\'en repart jamais)'); + }); + }); + + group('boucle complète — l\'échec redescend, il ne coince pas', () { + test('tap-out à chaque séance : la profondeur proposée suit', () async { + final rounds = await _loop( + comfort: _mid, + best: _throat, + sessions: 4, + tapOutOn: const {1, 2, 3, 4}, + ); + expect(rounds[0].tappedOut, isTrue, + reason: 'le cran sondé l\'expose vraiment — sur origin/develop rien ' + 'ne dépasse son comfort, elle n\'est jamais mise en difficulté'); + expect(rounds.map((r) => r.comfort).toList(), [_head, 0, 0, 0], + reason: 'un tap-out imputé coûte un cran, plancher à 0'); + for (final r in rounds) { + expect(r.deepestRhythm, lessThanOrEqualTo(r.comfortBefore + 1), + reason: 'la sonde ne propose jamais plus d\'un cran au-dessus du ' + 'comfort courant : ce qu\'elle ne tient pas redescend'); + } + }); + + test('tap-out puis reprise : throat récupéré 4 séances plus tard', + () async { + final rounds = await _loop( + comfort: _mid, + best: _throat, + sessions: 6, + tapOutOn: const {1}, + ); + expect(rounds.map((r) => r.comfort).toList(), + [_head, _head, _head, _mid, _throat, _throat], + reason: 'le tap-out casse la confiance (EMA vers 0) autant qu\'il ' + 'baisse le cran : il faut deux séances propres pour repasser ' + 'kDepthCranGate avant que les crans reviennent'); + }); + }); +} From 97312b1f9aeee0d883fcbd973a2017cacfa8f412 Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:00:26 +0200 Subject: [PATCH 6/6] =?UTF-8?q?docs(career):=20dit=20ce=20que=20la=20forme?= =?UTF-8?q?=20ternaire=20du=20cap=20de=20profondeur=20prot=C3=A8ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La relecture adverse l'a relevé : la reformulation n'est pas équivalente au `min` d'origine, et l'écart tombe exactement sur le cas que l'autre fichier du correctif protège explicitement (`best < comfort`, écrit tel quel par l'import de profil). Sans cette note, un refactor qui « revient à la forme littérale » réintroduit la régression. --- rhythm_coach/lib/career/services/career_level_gates.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/rhythm_coach/lib/career/services/career_level_gates.dart b/rhythm_coach/lib/career/services/career_level_gates.dart index d091c66..417a53a 100644 --- a/rhythm_coach/lib/career/services/career_level_gates.dart +++ b/rhythm_coach/lib/career/services/career_level_gates.dart @@ -115,6 +115,15 @@ class CareerLevelGates { // jamais `comfort`, donc le régulateur n'a jamais l'overshoot qui le ferait // remonter : descente facile, remontée verrouillée. Jamais au-delà du // `best` — on ne sonde que du territoire déjà prouvé. + // + // Écrit en ternaire, et **pas** `min(rounded + 1, best.round())` : les + // deux formules ne coïncident que tant que `best >= comfort`. Sur un + // profil incohérent (`best < comfort`, atteignable par un import de + // profil, qui écrit les deux clés sans passer par le régulateur), le + // `min` rabaisserait le cap SOUS le comfort — l'inverse de ce qu'on + // corrige. C'est le pendant du `max(comfort, …)` explicite de + // `capability_clamps.dart`, ici porté par la forme. Le test `cap(3, 2)` + // de `capability_depth_comfort_vs_best_test.dart` sépare les deux. final rounded = comfort.round(); final best = profile.bestOf(CapabilityAxis.rhythmDepthMax) ?? comfort; final probe = best.round() > rounded ? rounded + 1 : rounded;