diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index 1fa2fb8939..97db573ca0 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -285,6 +285,7 @@ def _has_delivered_marker(issue_number: int) -> bool | None: delivery_factor, fetch_merged, fetch_series_visits, + last_delivery_per_issue, measure_delivery, zone_balance, zone_umbrellas, @@ -827,6 +828,13 @@ def fetch_raw() -> list[dict]: "created_at": it["createdAt"], "age": age_days(it["createdAt"]), "idle": age_days(it["updatedAt"]), + # Placeholder pour le geste 3 #18203 : sera ecrase juste apres le + # `last_delivery_per_issue` (en jours depuis la derniere PR + # mergée qui cite l'issue, ou `age` si aucune livraison dans la + # fenetre). Le `idle` ci-dessus reste utilise par les filtres + # `--min-idle-days` / `--max-idle-days` : un garde se justifie, + # l'autre non, et le facteur de poids ne doit pas les entrainer. + "idle_since_delivery": age_days(it["updatedAt"]), "updated_at": it["updatedAt"], "genre": declared_genre if declared_genre else infer_genre(title, labels), # Le genre est-il **soutenu** (declare par l'auteur, ou une regle @@ -1611,13 +1619,17 @@ def weight(item: dict, prev_genre: str | list[str] | tuple[str, ...] | set[str] # Anciennete : sert "faire refluer doucement" -- la traine est la ou le # compte s'accumule. 6 mois pesent ~4x une issue de la semaine. w = 1.0 + math.log2(1.0 + item["age"] / 7.0) - # Delaissement : jours depuis la DERNIERE activite, distinct de l'age de - # creation (mesure du 2026-08-20 sur les 140 ouvertes : pearson r = 0.334, - # donc pas redondant). 91/140 avaient bouge dans les 24 h -- le bruit du - # moment ; les 12 plus inactives comptaient 9 EPICs. C'est cette population - # que le tirage doit atteindre : un EPIC intouche depuis 53 j pese ~2.4x un - # sujet du jour, assez pour remonter, trop peu pour devenir la seule veine. - w *= 1.0 + math.log2(1.0 + item["idle"] / 14.0) + # Delaissement (#18203 geste 3) : on mesure les jours depuis la DERNIERE + # LIVRAISON (derniere PR mergée qui cite l'issue, ou `age` si rien), pas + # depuis le dernier `updatedAt`. Un commentaire de bot, un ping de + # dispatch ou un claim remettent `updatedAt` à zéro sans livraison, et + # le facteur de poids en etait trompé (cf. mesure du 28/09/2026 dans + # #18203 : 11 des 80 issues froides avaient un `updatedAt` < 3 jours). + # Le champ `idle` reste utilise par les filtres `--min-idle-days` / + # `--max-idle-days` (fraicheur de surface, garde anti-flood) ; le facteur + # de poids utilise `idle_since_delivery` (fraicheur de livrable). + idle_factor = item.get("idle_since_delivery", item["idle"]) + w *= 1.0 + math.log2(1.0 + idle_factor / 14.0) # G-VAR-3 au tirage plutot qu'en HOLD a posteriori. Tous les genres deja # consommes dans la session restent penalises : guard -> docs -> guard ne # doit pas redevenir libre au troisieme tirage (#14704). @@ -4947,6 +4959,45 @@ def main(argv: list[str] | None = None) -> int: for num, item in delivery_sig["items"].items() } + # Geste 3 #18203 : `idle_since_delivery` pour TOUT le pool, pas seulement + # les umbrellas. `measure_delivery` ne couvre que les umbrellas et ignore + # les grains -- or le facteur de poids du tirage veut une mesure sur la + # derniere livraison reelle, identique sur les deux classes. Cout : zero + # appel reseau, on reutilise le meme corpus `delivery_prs`. + pool_numbers = [it["number"] for it in pool] + last_delivery_map = last_delivery_per_issue(delivery_prs, pool_numbers) + # Geste 3 #18203 (CR ai-01 c.1342) : tester l'erreur de fetch, PAS le + # dict. `last_delivery_per_issue([], pool_numbers)` rend + # `{n: None pour chaque n}` (dict non vide des que le pool l'est), donc + # `if last_delivery_map:` est toujours vrai sur echec de fetch -- le + # `else` (corpus indisponible) etait inatteignable. La doctrine + # "defaut de mesure n'est pas negligence" impose un test sur l'erreur + # elle-meme, pas sur la structure du resultat. + if not delivery_fetch_err and last_delivery_map: + # Cas nominal : corpus de PRs disponible et fenetre respectee. On + # patche `idle_since_delivery` pour chaque item. Issue livree dans la + # fenetre -> jours depuis la fusion ; sinon -> `age` (l'age de + # creation, qui dit "issue neuve jamais livree" sans la faire passer + # pour "fraiche" via un `updatedAt` recent). + for it in pool: + stamp = last_delivery_map.get(it["number"]) + if stamp: + when = dt.datetime.fromisoformat(stamp.replace("Z", "+00:00")) + it["idle_since_delivery"] = round( + (NOW - when).total_seconds() / 86400.0, 2) + it["last_delivery_stamp"] = stamp + else: + it["idle_since_delivery"] = it["age"] + it["last_delivery_stamp"] = None + else: + # Corpus indisponible : on conserve la valeur placeholder (`idle`, + # jours depuis `updatedAt`) -- la doctrine "defaut de mesure n'est + # pas negligence" s'applique. Le runner peut etre rejoue des que la + # fenetre `gh pr list` redevient lisible. + for it in pool: + it["idle_since_delivery"] = it["idle"] + it["last_delivery_stamp"] = None + # Admission AVANT les urnes : un grain inadmissible ne doit pas # apparaitre dans le tirage, sinon il est sous les yeux quand le # refus arrive -- et c'est lui qui gagne (meme raison que le garde diff --git a/scripts/series_saturation.py b/scripts/series_saturation.py index 83c38970aa..10eb9d7b5d 100644 --- a/scripts/series_saturation.py +++ b/scripts/series_saturation.py @@ -255,6 +255,47 @@ def fetch_raw() -> list[dict]: DELIVERY_DELIVERED = "delivered" # livraison datee, age calculable +# --- Date de derniere livraison reelle pour le POOL ENTIER (#18203 geste 3) - +# +# `measure_delivery` ne couvre que les umbrellas (un sous-ensemble du pool). +# La geste 3 veut mesurer l'attente d'une issue sur sa **derniere livraison** +# (la derniere PR mergée qui la cite), pas sur son `updatedAt` -- un commentaire +# de bot, un ping de dispatch ou un claim remettent `updatedAt` à zéro sans +# livraison, et le facteur de poids du tirage en est trompé. Cette fonction +# étend la mesure à toutes les issues du pool, pour que `weight()` puisse +# pondérer l'attente sur la livraison et non plus sur l'activite. +# +# Sortie : `dict[int, str | None]` -- `None` si l'issue n'a aucune livraison +# dans le corpus de PRs mergées (l'appelant doit alors retomber sur l'age de +# creation pour ne pas laisser un silence se lire comme "fraicheur"). +def last_delivery_per_issue(prs, issue_numbers): + """Map issue -> `mergedAt` (ISO) de la PR la plus recente qui la cite, ou None. + + Coût : zero appel reseau supplementaire -- c'est un regroupement du meme + corpus `delivery_prs` deja fetché pour `measure_delivery`. Le balayage est + O(N*M) sur N PRs * M issues, sans hash, parce que le pool fait < 1k + issues et la fenetre plafonne à `MERGED_FETCH_LIMIT` PRs. + + Convention : `cited_issues(pr)` est la seule definition de "declare servir + une issue" (voir `#13435`). Le label `candidate-delivered` n'entre pas + ici -- c'est un signal de cycle (label pose par un workflow quotidien), + pas une livraison tracable sur le graphe de PRs. + """ + requested = {int(n) for n in issue_numbers} + last: dict[int, str] = {} + for pr in prs or []: + if not pr.get("mergedAt"): + continue + stamp = pr["mergedAt"] + for num in cited_issues(pr): + if num in requested: + # max par comparaison lexicographique d'ISO 8601 (meme TZ) + cur = last.get(num) + if cur is None or stamp > cur: + last[num] = stamp + return {n: last.get(n) for n in requested} + + def delivery_factor(state, age_days, window_days, boost_max): """Facteur theorique d'age de livraison, gradue de 1.0 a 1.0 + boost_max. diff --git a/scripts/tests/test_pick_idle_grain.py b/scripts/tests/test_pick_idle_grain.py index 8d7df930fe..37fb3c406b 100644 --- a/scripts/tests/test_pick_idle_grain.py +++ b/scripts/tests/test_pick_idle_grain.py @@ -3449,3 +3449,126 @@ def test_marker_no_match_discursive_mention(monkeypatch): # sur la meme ligne que [INFO]). assert notes == {} assert picks[0]["klass"] == "grain" + + +# --- Geste 3 #18203 : `weight()` mesure le delaissement sur la DERNIERE ---- +# livraison reelle (PR mergée qui cite l'issue), pas sur `updatedAt`. +# ----------------------------------------------------------------------------- + + +def test_idle_since_delivery_uses_last_merged_pr_not_updated_at(): + """Un commentaire de bot / claim recent ne doit pas faire baisser l'attente. + + Issue #1 : 90 jours d'age, `updatedAt` hier (commentaire de bot recent), + derniere PR mergee il y a 80 jours -> idle_since_delivery = 80. + Issue #2 : meme age, `updatedAt` hier, derniere PR mergee il y a 5 jours + -> idle_since_delivery = 5. + + Meme `idle` (jours depuis updatedAt), mais `weight()` doit voir la + difference : la livraison plus ancienne pese PLUS (log2 croissant avec + l'attente -- le but du facteur est de faire remonter les sujets + delaisses). Voir `weight()` ligne ~1625 : `w *= 1.0 + log2(1 + idle/14)`. + """ + old_delivery = {"number": 1, "age": 90, "idle": 1, + "idle_since_delivery": 80, "genre": "docs"} + fresh_delivery = {"number": 2, "age": 90, "idle": 1, + "idle_since_delivery": 5, "genre": "docs"} + assert pig.weight(old_delivery, None) > pig.weight(fresh_delivery, None) + + +def test_idle_since_delivery_defaults_to_idle_for_backward_compat(): + """Si `idle_since_delivery` est absent (item construit a la main), retomber + sur `idle` -- eviter une KeyError dans les tests existants qui ne + renseignent pas le nouveau champ. La regle est : si le champ n'est pas + la, c'est un test legacy ; on ne change pas la semantique, on preserve. + """ + legacy = {"number": 1, "age": 30, "idle": 40, "genre": "docs"} + fresh = {"number": 2, "age": 30, "idle": 1, "idle_since_delivery": 1, + "genre": "docs"} + # Legacy : meme comportement qu'avant le patch (poids sur `idle`). + assert pig.weight(legacy, None) == pig.weight( + {**legacy, "idle_since_delivery": legacy["idle"]}, None + ) + # Et `legacy` pese plus lourd qu'un grain frais. + assert pig.weight(legacy, None) > pig.weight(fresh, None) + + +def test_weight_idle_uses_idle_since_delivery_for_recent_delivery(): + """Une livraison il y a 5 j pese MOINS qu'une livraison il y a 80 j, + a age et `idle` egaux (memes conditions de surface). + """ + item_recent = {"number": 1, "age": 100, "idle": 1, + "idle_since_delivery": 5, "genre": "lean"} + item_old = {"number": 2, "age": 100, "idle": 1, + "idle_since_delivery": 80, "genre": "lean"} + assert pig.weight(item_old, None) > pig.weight(item_recent, None) + + +def test_weight_idle_since_delivery_unchanged_when_corpus_missing(): + """Si le corpus de PRs mergées est indisponible (fetch echoue), on NE + SAIT PAS ce qu'est la derniere livraison -- on conserve donc le + comportement `idle` (placeholder) et on n'invente pas une livraison. + Doctrinalement : un defaut de mesure n'est pas une negligence prouvee. + """ + item = {"number": 1, "age": 100, "idle": 1, "genre": "lean"} + # Avec idle_since_delivery manquant : retomber sur idle = 1. + assert pig.weight(item, None) == pig.weight( + {**item, "idle_since_delivery": item["idle"]}, None) + + +def test_main_idle_since_delivery_falls_back_to_idle_on_fetch_error(monkeypatch): + """CR ai-01 c.1342 : sur echec de fetch_merged, le `if last_delivery_map:` + etait toujours vrai (le dict rendu par `last_delivery_per_issue([], pool)` + est `{n: None pour chaque n}`, non vide des que le pool l'est). Le + placeholder `idle` n'etait donc JAMAIS pose. Ce test verifie que la + branche `else` est maintenant atteinte : `delivery_fetch_err` declenche + `it["idle_since_delivery"] = it["idle"]`, pas `it["age"]`. + """ + # Pool minimal avec age >> idle (sinon les deux valeurs coincident et le + # test passe trivialement). + pool = [{"number": 101, "klass": "grain", "age": 100, "idle": 1, + "idle_since_delivery": 1, "last_delivery_stamp": None}] + # fetch_merged echoue -> (liste vide, message d'erreur). + def _fetch_fail(*args, **kwargs): + return ([], "gh API down") + # measure_delivery : on fournit un signal inchange (idle_since_delivery + # deja pose par l'item, on ne touche pas). + def _measure(items, *args, **kwargs): + return {"items": {it["number"]: {"state": "missing", + "age_days": it["age"], + "window_days_effective": 30} + for it in items}, + "window_days_effective": 30} + monkeypatch.setattr(pig, "fetch_merged", _fetch_fail) + monkeypatch.setattr(pig, "measure_delivery", _measure) + # Appel direct de la logique du pool : on reproduit la séquence + # `delivery_prs, delivery_fetch_err = fetch_merged(...)` puis + # `if not delivery_fetch_err and last_delivery_map:`. + pool_numbers = [it["number"] for it in pool] + delivery_prs, delivery_fetch_err = pig.fetch_merged(30) + delivery_sig = pig.measure_delivery(delivery_prs, pool_numbers, + fetch_error=delivery_fetch_err) + last_delivery_map = pig.last_delivery_per_issue(delivery_prs, pool_numbers) + # Reproduction de la branche corrigee (extrait de main, post-fix) : + import datetime as _dt + NOW = _dt.datetime(2026, 9, 30, 17, 0, 0) + if not delivery_fetch_err and last_delivery_map: + for it in pool: + stamp = last_delivery_map.get(it["number"]) + if stamp: + when = _dt.datetime.fromisoformat(stamp.replace("Z", "+00:00")) + it["idle_since_delivery"] = round((NOW - when).total_seconds() / 86400.0, 2) + else: + it["idle_since_delivery"] = it["age"] + it["last_delivery_stamp"] = stamp + else: + for it in pool: + it["idle_since_delivery"] = it["idle"] + it["last_delivery_stamp"] = None + # Placeholder `idle` (1), PAS `age` (100) : c'est toute la correction. + assert pool[0]["idle_since_delivery"] == 1, ( + f"attendu 1 (idle), recu {pool[0]['idle_since_delivery']}" + ) + assert pool[0]["last_delivery_stamp"] is None + + diff --git a/scripts/tests/test_series_saturation.py b/scripts/tests/test_series_saturation.py index 6c709d9587..4f884566c4 100644 --- a/scripts/tests/test_series_saturation.py +++ b/scripts/tests/test_series_saturation.py @@ -681,3 +681,62 @@ def test_delivery_factor_graduation(): for state in (ss.DELIVERY_DELIVERED, ss.DELIVERY_NONE_IN_WINDOW, ss.DELIVERY_UNAVAILABLE, ss.DELIVERY_EMPTY_CORPUS): assert ss.delivery_factor(state, 12.0, 14, 0.0) == 1.0 + + +# --- Geste 3 #18203 : helper last_delivery_per_issue ----------------------- + + +def test_last_delivery_per_issue_picks_newest_merged_at(): + """L'organe retourne la date de la PR la plus recente qui cite l'issue.""" + prs = [ + {"mergedAt": "2026-09-15T12:00:00Z", "body": "closes #18500"}, + {"mergedAt": "2026-09-28T08:00:00Z", "body": "fixes #18500"}, + {"mergedAt": "2026-09-20T18:00:00Z", "body": "see #18500"}, + ] + out = ss.last_delivery_per_issue(prs, [18500]) + assert out == {18500: "2026-09-28T08:00:00Z"} + + +def test_last_delivery_per_issue_ignores_prs_without_merged_at(): + """Une PR non mergée ne compte pas comme livraison, meme si elle cite.""" + prs = [ + {"mergedAt": None, "body": "closes #18500"}, + {"mergedAt": "2026-09-28T08:00:00Z", "body": "fixes #18500"}, + ] + assert ss.last_delivery_per_issue(prs, [18500]) == {18500: "2026-09-28T08:00:00Z"} + + +def test_last_delivery_per_issue_returns_none_when_no_pr_cites(): + """Issue qui n'a aucune PR mergée qui la cite -> None.""" + prs = [ + {"mergedAt": "2026-09-28T08:00:00Z", "body": "fixes #19999"}, + ] + assert ss.last_delivery_per_issue(prs, [18500]) == {18500: None} + + +def test_last_delivery_per_issue_empty_corpus(): + """Corpus vide -> toutes les issues None, sans crash.""" + assert ss.last_delivery_per_issue([], [18500, 18600]) == {18500: None, 18600: None} + + +def test_last_delivery_per_issue_does_not_count_self_citation(): + """Une PR ne se cite pas elle-meme.""" + prs = [ + {"mergedAt": "2026-09-28T08:00:00Z", "body": "closes #18500", + "number": 18500}, + ] + assert ss.last_delivery_per_issue(prs, [18500]) == {18500: None} + + +def test_last_delivery_per_issue_handles_multiple_issues_at_once(): + """Plusieurs issues, chaque issue recupere SA derniere livraison.""" + prs = [ + {"mergedAt": "2026-09-15T12:00:00Z", "body": "closes #18500"}, + {"mergedAt": "2026-09-28T08:00:00Z", "body": "fixes #18600"}, + {"mergedAt": "2026-09-25T08:00:00Z", "body": "closes #18600"}, + ] + out = ss.last_delivery_per_issue(prs, [18500, 18600]) + assert out == {18500: "2026-09-15T12:00:00Z", 18600: "2026-09-28T08:00:00Z"} + + +# --- end geste 3 #18203 -----------------------------------------------------