Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 91 additions & 3 deletions scripts/pick_idle_grain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <machine:workspace>")
print(f" (`python scripts/check_lane_claim.py <N> {hint}`).")


def draw_verdict(pool_error: str | None) -> str:
"""La phrase du verdict quand la lecture du pool n'a PAS abouti (#17038).

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -2267,14 +2303,24 @@ 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
if it["number"] not in drawn]
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 "
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
133 changes: 133 additions & 0 deletions scripts/tests/test_pick_claim_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <N> --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 == ""
Loading