diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index a6e0c6e476..8ec20fa750 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -1644,6 +1644,37 @@ def print_delivered_signal_report( print() +def print_claim_unread_report(unread, lane: str | None = None) -> None: + """Rend compte des claims NON LUS -- la moitie manquante du fail-OPEN. + + Le tirage sert un candidat dont le claim n'a pas pu etre lu : c'est la + bonne doctrine (refuser un grain faute d'avoir pu verifier son claim + fabriquerait un faux BLOQUE). Le SILENCE qui l'accompagnait, lui, ne + l'etait pas : le candidat partait avec un statut de claim INCONNU, sans + que le rendu le distingue d'un claim verifie libre (#20265). Le signal de + livraison donne le patron -- il annonce deja sa lecture ratee + (``state["failures"]``) ; le claim fait de meme. + + Ne PAS confondre avec un controle ABSENT : ``--no-check-claims`` et un + tirage sans lane rendent le meme code de repli sans qu'aucune lecture ait + ete tentee. Ce cas ne se signale pas -- une banniere y serait une fausse + alerte a chaque tirage sans lane. L'appelant ne passe donc que les + numeros d'une lecture TENTEE ET ECHOUEE. + """ + numbers = sorted(set(unread or [])) + if not numbers: + return + joined = ", ".join(f"#{num}" for num in numbers) + print(f"!! claim NON LU sur {joined} ({len(numbers)} candidat(s)) : la " + "lecture n'a pas pu etre faite") + print(" (reseau, 403, payload illisible). Le tirage est MAINTENU et ces") + print(" candidats sont CONSERVES (fail-OPEN assume) -- on ne refuse pas un") + print(" grain faute d'avoir pu verifier son claim. Le statut est INCONNU,") + print(" pas libre : verifier avant d'editer") + hint = ("--lane " + lane if lane else "--lane ") + print(f" (`python scripts/check_lane_claim.py {hint}`).") + + def draw_verdict(pool_error: str | None) -> str: """La phrase du verdict quand la lecture du pool n'a PAS abouti (#17038). @@ -2199,6 +2230,11 @@ def draw_unclaimed(by_class, args, rng, visits, series, issue_to_family, else {"failures": [], "budget_hit": False, "recent_filtered": 0}) failures = state.setdefault("failures", []) recent_filtered = state.setdefault("recent_filtered", 0) + # Les numeros servis sur une lecture de claim EN ECHEC (#20265). + claim_unread = state.setdefault("claim_unread", []) + # Une lecture de claim n'est SIGNALABLE que si elle a ete TENTEE : le + # repli `ERROR` couvre aussi le controle jamais demande. + check_claims_ran = bool(args.check_claims and args.lane) budget = [DELIVERED_SIGNAL_MAX_PROBES] def _counted_probe(number, lane_name): @@ -2267,7 +2303,7 @@ def _counted_merged_pr_probe(number, lane_name): break nums = [c["number"] for c in cand] verdicts = (check_claims(nums, args.lane) - if args.check_claims and args.lane else {}) + if check_claims_ran else {}) claims.update(verdicts) drawn = {c["number"] for c in cand} pool = [it for it in pool @@ -2275,6 +2311,16 @@ def _counted_merged_pr_probe(number, lane_name): for c in cand: v_code, v_human = verdicts.get(c["number"], (CLAIM_CODE_ERROR, "")) + # Le repli `ERROR` sert DEUX cas opposes : une lecture + # tentee qui a echoue, et un controle jamais demande + # (`--no-check-claims`, tirage sans lane) qui laisse + # `verdicts` vide. Seul le premier est un fail-OPEN a + # signaler -- l'autre ferait une fausse alerte a chaque + # tirage sans lane (#20265). + if (check_claims_ran + and v_code in (CLAIM_CODE_ERROR, + CLAIM_CODE_UNCHECKED)): + claim_unread.append(c["number"]) if v_code == CLAIM_CODE_BLOCKED: conflicts.append((c, "CLAIM : " + v_human + ( ". Une autre lane tient ce grain -- ecrire dessus " @@ -6066,10 +6112,14 @@ def belt_pick_with_replacements(belt_pool, belt_claims, args, probe_budget, 2026-10-09 : 99 s pour un seul test, cf. coordinateur #19913). Rend ``(picks, withheld, etat)`` avec ``etat = {"failures": [numeros - illisibles], "budget_hit": bool}``. + illisibles], "budget_hit": bool, "claim_unread": [numeros servis sur une + lecture de claim en echec]}``. ``claim_unread`` est le pendant du + ``failures`` de livraison : deux lectures qui n'ont pas ABOUTI, servies + quand meme (fail-OPEN) et rapportees a la lane (#20265). """ budget = [int(probe_budget)] - state = {"failures": [], "budget_hit": False, "recent_filtered": 0} + state = {"failures": [], "budget_hit": False, "recent_filtered": 0, + "claim_unread": []} # Le budget enrobe TOUTE sonde, injectee ou reelle : un test qui fournit # sa sonde doit voir le plafond s'appliquer aussi -- sinon la borne de @@ -6145,6 +6195,13 @@ def claims_probe(numbers): # ERROR (check indisponible, parse rate...) est servable par # defaut : on ne peut pas refuser un grain faute d'avoir pu # verifier son claim, ce serait introduire un faux BLOQUE. + # Le fail-OPEN est assume, le SILENCE ne l'est pas (#20265) : un + # candidat servi sur une lecture en echec part avec un statut de + # claim INCONNU, et rien ne le distinguait d'un claim verifie libre. + # Le signal de livraison annoncait deja sa lecture ratee ; le claim + # le fait ici, au moment ou le candidat est retenu. + if args.lane and code in (CLAIM_CODE_ERROR, CLAIM_CODE_UNCHECKED): + state["claim_unread"].append(n) belt_picks.append(it) return belt_picks, belt_withheld, state @@ -6933,6 +6990,10 @@ def _probe(n: int) -> str | None: print(f"!! signal de livraison NON LU sur {numbers} : le tirage") print(" est MAINTENU et ces candidats CONSERVES -- une lecture") print(" qui n'a pas ABOUTI n'est PAS une absence de signal.") + # #20265 : meme doctrine pour le claim, juxtaposee a la ligne + # ci-dessus -- les deux lectures ratees se lisent ensemble. + print_claim_unread_report(belt_pick_state.get("claim_unread"), + args.lane) if belt_probe_budget_hit[0]: print(" Plafond de sondes de livraison atteint : les candidats") print(" au-dela ne sont pas sondes (fail-OPEN) -- la verification") @@ -6982,6 +7043,17 @@ def _probe(n: int) -> str | None: "unread": sorted(set(belt_delivered_failures)), "budget_hit": belt_probe_budget_hit[0], }, + # Volet claim (#20265) : le pendant du volet livraison. Une + # liste vide dit « toutes les lectures TENTEES ont abouti » -- + # jamais « aucun candidat ». Un controle non demande + # (`--no-check-claims`, tirage sans lane) n'y figure pas : le + # code de repli y est indistinguable d'un echec, seul le + # premier est un fail-OPEN a signaler. + "claim_signal": { + "probes_attempted": bool(args.lane), + "unread": sorted(set(belt_pick_state.get("claim_unread") + or [])), + }, "last_delivery_window_days": delivery_window_days, "substance_drought": {"triggered": False, "measured": False, "run": 0, "mode": "belt-bypassed"}, @@ -7238,6 +7310,17 @@ def _probe(n: int) -> str | None: "budget_hit": delivered_state["budget_hit"], "max_probes": DELIVERED_SIGNAL_MAX_PROBES, }, + # Pendant du volet livraison, meme doctrine (#20265) : + # `probes_attempted` est la parce que ce chemin peut legitimement + # NE PAS sonder (`--no-check-claims`, tirage sans lane) -- sans + # elle, une liste vide se lirait « tout a abouti » alors qu'elle + # peut dire « rien n'a ete mesure ». Les deux etats ne se disent + # pas avec le meme silence. + "claim_signal": { + "probes_attempted": bool(args.check_claims and args.lane), + "unread": sorted(set(delivered_state.get("claim_unread") + or [])), + }, "red_backlog": backlog, "substance_drought": drought, "lane_record": lane_record, @@ -7428,6 +7511,11 @@ def _probe(n: int) -> str | None: print(pad + "rien : prendre un sous-grain ailleurs, sauf steer explicite.") print_delivered_signal_report(withheld, delivered_state, args.include_delivered) + # #20265 : juxtapose au signal de livraison -- les deux lectures ratees + # se lisent ensemble, la ou le defaut etait precisement que l'une se + # taisait. + print_claim_unread_report((delivered_state or {}).get("claim_unread"), + args.lane) print_empty_draw_notice(withheld, picks, args.include_delivered) print() print_unattributed_blocked(backlog) diff --git a/scripts/tests/test_pick_claim_filter.py b/scripts/tests/test_pick_claim_filter.py index 380e57287f..11de03093c 100644 --- a/scripts/tests/test_pick_claim_filter.py +++ b/scripts/tests/test_pick_claim_filter.py @@ -180,3 +180,136 @@ def test_deja_claim_par_cette_lane_n_est_pas_un_conflit(): pick.check_claims = real assert [p["number"] for p in picks] == [1] assert conflicts == [] + + +# --- #20265 : le fail-OPEN du claim, et sa moitie RAPPORTEE ---------------- +# +# Le tirage servait deja un candidat dont le claim n'avait pas pu etre lu +# (fail-OPEN, correct) mais SE TAISAIT : le candidat partait avec un statut +# de claim INCONNU, indiscernable d'un claim verifie libre. Les tests +# ci-dessous sont apparies en TEMOINS DISCRIMINANTS : le positif (echec -> +# trace) ne vaut que par le negatif (meme sonde, reussie -> trace ABSENTE). +# Un test qui n'exerce que l'echec ne prouve pas que la trace ne se declenche +# pas a tort. + + +def _probe_returns(**verdicts): + """Fabrique une sonde de claim rendant le meme verdict pour tout numero.""" + def probe(nums, lane): + return {n: verdicts["v"] for n in nums} + return probe + + +def test_lecture_de_claim_en_echec_est_servie_mais_tracee(): + """Temoin POSITIF : la sonde n'aboutit pas -> le candidat est CONSERVE + (fail-OPEN) et son numero est rapporte dans l'etat.""" + real = pick.check_claims + pick.check_claims = _probe_returns( + v=(pick.CLAIM_CODE_ERROR, "lecture ratee (reseau)")) + state = {} + try: + picks, _, conflicts = pick.draw_unclaimed( + dict(EMPTY, grain=[_it(1)]), _args(), random.Random(7), + None, None, None, delivered_state=state) + finally: + pick.check_claims = real + assert [p["number"] for p in picks] == [1], "le fail-OPEN doit conserver" + assert conflicts == [], "un ERROR n'est pas un BLOQUE" + assert state["claim_unread"] == [1], "lecture ratee non rapportee" + + +def test_sonde_qui_aboutit_en_free_ne_trace_rien(): + """Temoin NEGATIF -- la moitie discriminante : la MEME sonde, reussie en + FREE, ne doit RIEN rapporter. Sans ce test, une trace qui se declenche a + tort passerait pour une reussite.""" + real = pick.check_claims + pick.check_claims = _probe_returns(v=(pick.CLAIM_CODE_FREE, "libre")) + state = {} + try: + picks, _, _ = pick.draw_unclaimed( + dict(EMPTY, grain=[_it(1)]), _args(), random.Random(7), + None, None, None, delivered_state=state) + finally: + pick.check_claims = real + assert [p["number"] for p in picks] == [1] + assert state.get("claim_unread") == [], "lecture reussie mais rapportee" + + +def test_controle_non_demande_ne_declenche_pas_de_fausse_alerte(): + """Le repli `ERROR` couvre DEUX cas opposes : une lecture TENTEE qui + echoue, et un controle JAMAIS DEMANDE (`--no-check-claims`, tirage sans + lane). Seul le premier est un fail-OPEN a signaler -- sinon chaque tirage + sans lane alerterait sur tous ses candidats.""" + real = pick.check_claims + + def _never(nums, lane): + raise AssertionError("check_claims appele alors qu'aucune lecture " + "n'a ete demandee") + + pick.check_claims = _never + state = {} + try: + for kw in ({"check_claims": False}, {"lane": None}): + picks, _, _ = pick.draw_unclaimed( + dict(EMPTY, grain=[_it(1)]), _args(**kw), random.Random(7), + None, None, None, delivered_state=state) + assert [p["number"] for p in picks] == [1] + finally: + pick.check_claims = real + assert state.get("claim_unread") == [], ( + "un controle non demande a produit une alerte") + + +# --- Le tapis (2e chemin) : meme fail-OPEN, meme trace -------------------- +# +# Le tapis n'emprunte pas `draw_unclaimed` : sa boucle de service est +# `belt_pick_with_replacements`. Les deux chemins sont couverts ici plutot +# que supposes convergents -- c'est le tapis que lance la Phase 2 du cycle. + + +def _belt(minimal_number, code, human, lane="myia-po-2099:CoursIA"): + import types + args = types.SimpleNamespace(grains=1, include_delivered=True, lane=lane) + return pick.belt_pick_with_replacements( + [{"number": minimal_number, "klass": "grain"}], + {minimal_number: (code, human)}, args, probe_budget=4) + + +def test_tapis_trace_la_lecture_de_claim_en_echec(): + picks, _, state = _belt(1, pick.CLAIM_CODE_ERROR, "lecture ratee") + assert [p["number"] for p in picks] == [1], "le tapis doit servir" + assert state["claim_unread"] == [1] + + +def test_tapis_ne_trace_pas_une_lecture_reussie(): + picks, _, state = _belt(1, pick.CLAIM_CODE_FREE, "libre") + assert [p["number"] for p in picks] == [1] + assert state["claim_unread"] == [] + + +def test_tapis_sans_lane_ne_trace_pas(): + """Sans lane, le tapis tire des lanes non identifiees (`--admissible`) : + une alerte y serait du bruit systematique.""" + picks, _, state = _belt(1, pick.CLAIM_CODE_ERROR, "lecture ratee", + lane=None) + assert [p["number"] for p in picks] == [1] + assert state["claim_unread"] == [] + + +# --- Le rendu : la trace est VISIBLE, et muette quand il n'y a rien ------- + + +def test_le_rapport_rend_la_trace_visible(capsys): + pick.print_claim_unread_report([20265], "myia-po-2024:CoursIA-2") + out = capsys.readouterr().out + assert "claim NON LU" in out + assert "#20265" in out + assert "check_lane_claim.py --lane myia-po-2024:CoursIA-2" in out + + +def test_le_rapport_est_muet_sans_lecture_ratee(capsys): + """Contre-epreuve du rendu : un rapport qui bavarde a vide serait du + bruit a chaque tirage -- y compris ceux ou tout s'est bien passe.""" + pick.print_claim_unread_report([], "myia-po-2024:CoursIA-2") + pick.print_claim_unread_report(None, "myia-po-2024:CoursIA-2") + assert capsys.readouterr().out == ""