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
65 changes: 58 additions & 7 deletions scripts/pick_idle_grain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions scripts/series_saturation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
123 changes: 123 additions & 0 deletions scripts/tests/test_pick_idle_grain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


59 changes: 59 additions & 0 deletions scripts/tests/test_series_saturation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -----------------------------------------------------
Loading