Observatoire des formes relationnelles — Cas 1

Bumble « Opening Moves » (mars–avril 2024) — DiD imparfait sur avis datés

Cas inaugural de l’Observatoire (#13303) — protocole de preuve #13309 — instrument \(G_t^{arg} \to G_{t+1}^{arg}\) rebranché depuis Argumentation-Obs-01-Graphes-Dates-Python.ipynb (livré #13310).

Grain: DEEP/research-code — lane myia-po-2027:CoursIA — prev: MED/guard #16388

1. Position dans le protocole

Premier cas de l’Observatoire. La séquence du protocole est tenue :

  1. Protocole (#13309) — règle de preuve, barreau 1→3, biais de sélection nommé.
  2. Instrument (#13310) — l’écart structurel daté d_struct, son plancher split-half, son contrôle positif par construction.
  3. Cas (présent notebook) — premier candidat nommé dans le body de l’EPIC : Bumble « Opening Moves », rupture datée, bras contrôle Hinge sans changement sur la même fenêtre.

Barreau visé : 1 → éventuellement 2. Jamais 3 (toute conclusion causale récusée par construction). Cf. #13303 §Volet Désir.

2. Pré-inscription

Rappel — voir commentaire d’inscription (#16410) pour la version complète.

Élément Valeur
Rupture Bumble « Opening Moves » — déploiement 2024-04-30 (couverture presse 2024-05-01)
Fenêtre pré 2023-10-15 → 2024-04-29 (6 mois)
Fenêtre post 2024-05-15 → 2024-11-15 (6 mois + tampon de rollout 15 j)
Bras traité r/Bumble (textes publics Reddit datés)
Bras contrôle r/hingeapp (aucun changement de règle d’initiation sur la fenêtre)
Corpus source Arctic Shift (arctic-shift.photon-reddit.com)

Hypothèses signées :

  • H₀ : Δ_struct(Bumble_post, Bumble_pre) − Δ_struct(hingeapp_post, hingeapp_pre) ≤ plancher split-half. Les distributions de propositions n décalent pas différentiellement.
  • H₁ : décalage positif sur le bras traité uniquement, hausse de INIT_NORM_ANY et OPENING_MOVE_REACT, baisse de INIT_NORM_W, avec Δ_struct(traité) > Δ_struct(contrôle) + plancher.

Biais de sélection signé : r/Bumble sur-représente les utilisateurs investis et les événements saillants. Direction : pousse vers H₁ (la rupture elle-même fabrique des posts OPENING_MOVE_REACT). Le bras contrôle n’absorbe pas ce biais de saillance spécifique au traité.

Plafond revendiqué : barreau associatif au mieux, « quasi-expérimental » inatteignable avec ce corpus (commentaire absent / utilisateurs silencieux / résiliés / hors-app / non-anglophones).

3. Engagement de corpus

Politique dataset (CLAUDE.md §Secrets-hygiene) : aucun corpus brut commité. Seuls les agrégats, comptes et verbatims bornés (≤5 par cellule de mesure, anonymisés : URLs retirées, /u/ et @ redactés) entrent dans le notebook. Aucun identifiant auteur n’est conservé.

Source : Arctic Shift, endpoint GET /api/posts/search, paramètres subreddit, after, before, limit (max 100/requête).

Échantillonnage stratifié mensuel : chaque fenêtre (6 mois) est découpée en 6 seaux ~30 jours ; on tire par seau un nombre borné de posts récents (~9). Comptes par seau imprimés à l’exécution (sanity de fenêtre : l’agrégat date_min/date_max doit couvrir la période pré-inscrite). Objectif ≈ 50 textes publics par (bras × fenêtre), chacun classifié (aucun sous-échantillon) — 4 cellules × ~50 = ~200 classifications, modèle local Ollama.

Modèle d’extraction : Ollama local qwen2.5:7b-instruct-q4_K_M, température 0, prompt épinglé (catégories + oppositions), schéma JSON épinglé. Aucun appel distant à clé d’API.

# --- Reprise de l'instrument Dated_Graphs ---
# Source : Argumentation-Obs-01-Graphes-Dates-Python.ipynb (#13310), converti en module
# `_dated_graphs_mod.py` (meme repertoire). Aucune re-ecriture : les symboles
# de l'instrument sont importes, jamais redefinis ici.

import importlib.util
from pathlib import Path

# Anchrage repo-root : nbconvert execute un notebook avec cwd = son repertoire
# (papermill --cwd peut chosir un autre point de depart). On remonte jusqu'a la
# racine qui porte MyIA.AI.Notebooks/, puis on atteint le module par chemin
# canonique -- meme regle que le substrat Dated_Graphs (Path relatif racine).
_ROOT = Path.cwd()
while not (_ROOT / "MyIA.AI.Notebooks").is_dir() and _ROOT.parent != _ROOT:
    _ROOT = _ROOT.parent

INSTR_DIR = _ROOT / "MyIA.AI.Notebooks" / "SymbolicAI" / "Argument_Analysis"
PY_PATH = INSTR_DIR / "_dated_graphs_mod.py"
if not PY_PATH.exists():
    raise RuntimeError(f"module instrument absent : {PY_PATH}")

spec = importlib.util.spec_from_file_location("_dated_graphs_mod", PY_PATH)
_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(_mod)

from _dated_graphs_mod import (  # noqa: F401,E402
    Enonce, AIFGraph, build_aif_graph, to_rdf,
    AF, defeats, defends, grounded, to_dung_af,
    jaccard_distance, d_struct, d_sem, split_half,
)
INSTRUMENT_OK = True
print(f"[instrument] module importe : {PY_PATH.name} "
      f"(Enonce, AIFGraph, build_aif_graph, to_dung_af, d_struct, d_sem, split_half)")
periode t (jours 1-30) : 30 enonces (28 assertifs, 2 questions), 22 relations exprimees
periode t+1 (jours 31-60) : 16 enonces (15 assertifs, 1 questions), 12 relations exprimees

Apercu des 5 premiers enonces de la periode t :
  jour  1 |   p1 | assertif | (aucune)
  jour  2 |   p2 | assertif | CA->p1
  jour  3 |   p3 | assertif | CA->p2
  jour  4 |   p4 | assertif | CA->p3
  jour  5 |   p5 | assertif | RA->p4
G_t : 13 noeuds-I, 11 noeuds de scheme (9 CA, 2 RA), 2 exclus
    exclu : p13 -- C1 : acte non assertif
    exclu : p14 -- C3 : singleton (aucune relation dans la fenetre)
G_t+1 : 14 noeuds-I, 12 noeuds de scheme (10 CA, 2 RA), 3 exclus
    exclu : p13 -- C1 : acte non assertif
    exclu : p14 -- C3 : singleton (aucune relation dans la fenetre)
    exclu : p17 -- C3 : singleton (aucune relation dans la fenetre)
Classes AIF declarees dans l'ontologie : ['CA-node', 'Conflict', 'I-node', 'RA-node']
Conformite vocabulaire : ['CA-node', 'I-node', 'RA-node'] <= classes declarees : OK

G_t serialise : 59 triplets AIF
@prefix aif: <http://www.arg.dundee.ac.uk/aif#> .

aif:p10 a aif:I-node ;
    aif:RAedge aif:RA_p10_p9 ;
    aif:text "proposition p10" .

aif:p11 a aif:I-node ;
    aif:CAedge aif:CA_p11_p6 ;
    aif:text "proposition p11" .

aif:p12 a aif:I-node ;
    aif:CAedge aif:CA_p12_p9 ;
    aif:text "proposition p12" .

aif:p18 a aif:I-node ;
    aif:CAedge aif:CA_p18_p1 ;
    aif:text "proposition p18" .

aif:p5 a aif:I-node ;
    aif:RAedge aif:RA_p5_p4 ;
    aif:text "proposition p5" .

aif:CA_p11_p6 a aif:CA-node ;
  
AF_t   : 13 arguments, 9 attaques
AF_t+1 : 14 arguments, 10 attaques

E_t   (grounded, 8/13) : ['p10', 'p11', 'p12', 'p18', 'p2', 'p4', 'p5', 'p8']
E_t+1 (grounded, 8/14) : ['p10', 'p15', 'p16', 'p2', 'p4', 'p5', 'p7', 'p9']
Sanity : d(G, G) = 0 sur les trois composantes : OK
Moitie a (jours 1-15)  : 13 arguments, 9 attaques | grounded 8
Moitie b (jours 16-30) : 12 arguments, 8 attaques | grounded 7

Plancher de bruit (split moities de la periode t) :
  d_noeuds  = 0.077
  d_att     = 0.111
  d_sem     = 0.125
composante     ecart t->t+1   plancher   rapport  verdict
------------------------------------------------------------------------
d_noeuds              0.200      0.077      2.6x  DEPASSE
d_att                 0.273      0.111      2.5x  DEPASSE
d_sem                 0.667      0.125      5.3x  DEPASSE
Entrees dans l'extension grounded (acceptes en t+1, pas en t) : ['p15', 'p16', 'p7', 'p9']
Sorties de l'extension grounded (acceptes en t, plus en t+1)   : ['p11', 'p12', 'p18', 'p8']
Lecture des cascades :
  - p15->p12 (non contré) fait sortir p12 ; p12 sortant, p9 n'est plus
    contré -> p9 entre ; et p9 dans l'extension défait p8 -> p7 entre
    (son attaquant p8 est desormais defait).
  - p8 sort : il exigeait la defaite de son attaquant p9, qui n'etait
    defait QUE par p12 -- p12 sortant, p8 perd sa defense.
  - p16->p11 fait sortir p11 (non contré).
  - p18 n'est plus entendu en t+1 : son attaque disparait (avec son noeud).
  - p1 reste dehors : son attaquant p2 n'est jamais defait (p3, seul
    attaquant de p2, n'entre jamais dans l'extension -- p4 indéfaisable).
E_ctrl = ['p10', 'p11', 'p12', 'p18', 'p3', 'p5', 'p8', 'p_ctrl']

attendu (ecrit avant)   : d_sem = 0.4, d_att = 0.1
mesure                  : d_sem = 0.400, d_att = 0.100

Controle positif : d_sem CONFORME, d_att CONFORME a l'attendu ecrit
p4 sorti : True | p2 sorti : True | p3 entre : True | p_ctrl entre : True
Resume de l'instrument (toute mesure d'ecart avec son plancher) :
  plancher_bruit : {'d_noeuds': 0.077, 'd_att': 0.111, 'd_sem': 0.125}
  ecart_inter_dates : {'d_noeuds': 0.2, 'd_att': 0.273, 'd_sem': 0.667}
  controle_positif : {'d_sem_attendu': 0.4, 'd_sem_mesure': 0.4, 'd_att_attendu': 0.1, 'd_att_mesure': 0.1}
  exclusions : {'t': {'p13': 'C1 : acte non assertif', 'p14': 'C3 : singleton (aucune relation dans la fenetre)'}, 't+1': {'p13': 'C1 : acte non assertif', 'p14': 'C3 : singleton (aucune relation dans la fenetre)', 'p17': 'C3 : singleton (aucune relation dans la fenetre)'}}
Lecture cumulative :
  G_cumul_t  : 13 arguments, 9 attaques
  G_cumul_t1 : 15 arguments, 11 attaques

Hypothese monotone sur ce corpus :
  args    G_cumul_t inclus dans G_cumul_t1 : True
  attacks G_cumul_t inclus dans G_cumul_t1 : True
  -> VERIFIEE PAR CONSTRUCTION : sur des corpus emboites, build_aif_graph
     n'exclut jamais un noeud deja relie (C3 ne frappe que les singletons, et
     les relations s'accumulent). Ici la monotonie est un theoreme de
     l'instrument ; elle ne le resterait pas devant un corpus reel retire des enonces.
Grounded de G_cumul_t (8/13) : ['p10', 'p11', 'p12', 'p18', 'p2', 'p4', 'p5', 'p8']
Hors grounded (7) : ['p1', 'p15', 'p16', 'p3', 'p6', 'p7', 'p9']

Retrait d'un argument DEFAIT (hors grounded) — effet mesure un par un :
  retrait 'p1': d_noeuds=0.133 d_att=0.182 d_sem=0.111  d_sem BOUGE (0.111)
  retrait 'p15': d_noeuds=0.067 d_att=0.091 d_sem=0.500  d_sem BOUGE (0.500)
  retrait 'p16': d_noeuds=0.067 d_att=0.091 d_sem=0.200  d_sem BOUGE (0.200)
  retrait 'p3': d_noeuds=0.067 d_att=0.182 d_sem=0.000  d_sem immobile
  retrait 'p6': d_noeuds=0.067 d_att=0.182 d_sem=0.000  d_sem immobile
  retrait 'p7': d_noeuds=0.067 d_att=0.182 d_sem=0.200  d_sem BOUGE (0.200)
  retrait 'p9': d_noeuds=0.133 d_att=0.182 d_sem=0.455  d_sem BOUGE (0.455)
  -> 2 retraits laissent grounded inchange ; 5 le font bouger
     (un defait qui ATTAQUAIT un candidat peut, en disparaisant, le faire accepter)

Retrait de 'p10' [ACCEPTE, dans grounded] :
  d_noeuds = 0.067   d_att = 0.000   d_sem = 0.111

Ce que l'instrument fait sous violation :
  d_noeuds > 0 pour TOUT retrait : la mesure structurelle voit chaque disparition ;
  d_sem ne bouge QUE si le retrait touche l'extension grounded (directement, ou via
      un attaquant disparu qui libere un candidat) ;
  AUCUNE des deux mesures ne dit RETRAIT vs APPARITION : le sens du mouvement n'est
  pas dans l'ecart de graphes — il est dans les evenements dates du corpus. C'est
  la limite explicite que l'hypothese monotone permettait de ne pas poser.
Exercice 1 a completer : fonction definie, a vous de l'appeler sur af_t / af_t1
Exercice 2 a completer : fonction definie, a vous de produire la distribution
Exercice 3 a completer : fonctions definies, a vous de produire le classement
periode t (jours 1-30) : 30 enonces (28 assertifs, 2 questions), 22 relations exprimees
periode t+1 (jours 31-60) : 16 enonces (15 assertifs, 1 questions), 12 relations exprimees

Apercu des 5 premiers enonces de la periode t :
  jour  1 |   p1 | assertif | (aucune)
  jour  2 |   p2 | assertif | CA->p1
  jour  3 |   p3 | assertif | CA->p2
  jour  4 |   p4 | assertif | CA->p3
  jour  5 |   p5 | assertif | RA->p4
G_t : 13 noeuds-I, 11 noeuds de scheme (9 CA, 2 RA), 2 exclus
    exclu : p13 -- C1 : acte non assertif
    exclu : p14 -- C3 : singleton (aucune relation dans la fenetre)
G_t+1 : 14 noeuds-I, 12 noeuds de scheme (10 CA, 2 RA), 3 exclus
    exclu : p13 -- C1 : acte non assertif
    exclu : p14 -- C3 : singleton (aucune relation dans la fenetre)
    exclu : p17 -- C3 : singleton (aucune relation dans la fenetre)
Classes AIF declarees dans l'ontologie : ['CA-node', 'Conflict', 'I-node', 'RA-node']
Conformite vocabulaire : ['CA-node', 'I-node', 'RA-node'] <= classes declarees : OK

G_t serialise : 59 triplets AIF
@prefix aif: <http://www.arg.dundee.ac.uk/aif#> .

aif:p10 a aif:I-node ;
    aif:RAedge aif:RA_p10_p9 ;
    aif:text "proposition p10" .

aif:p11 a aif:I-node ;
    aif:CAedge aif:CA_p11_p6 ;
    aif:text "proposition p11" .

aif:p12 a aif:I-node ;
    aif:CAedge aif:CA_p12_p9 ;
    aif:text "proposition p12" .

aif:p18 a aif:I-node ;
    aif:CAedge aif:CA_p18_p1 ;
    aif:text "proposition p18" .

aif:p5 a aif:I-node ;
    aif:RAedge aif:RA_p5_p4 ;
    aif:text "proposition p5" .

aif:CA_p11_p6 a aif:CA-node ;
  
AF_t   : 13 arguments, 9 attaques
AF_t+1 : 14 arguments, 10 attaques

E_t   (grounded, 8/13) : ['p10', 'p11', 'p12', 'p18', 'p2', 'p4', 'p5', 'p8']
E_t+1 (grounded, 8/14) : ['p10', 'p15', 'p16', 'p2', 'p4', 'p5', 'p7', 'p9']
Sanity : d(G, G) = 0 sur les trois composantes : OK
Moitie a (jours 1-15)  : 13 arguments, 9 attaques | grounded 8
Moitie b (jours 16-30) : 12 arguments, 8 attaques | grounded 7

Plancher de bruit (split moities de la periode t) :
  d_noeuds  = 0.077
  d_att     = 0.111
  d_sem     = 0.125
composante     ecart t->t+1   plancher   rapport  verdict
------------------------------------------------------------------------
d_noeuds              0.200      0.077      2.6x  DEPASSE
d_att                 0.273      0.111      2.5x  DEPASSE
d_sem                 0.667      0.125      5.3x  DEPASSE
Entrees dans l'extension grounded (acceptes en t+1, pas en t) : ['p15', 'p16', 'p7', 'p9']
Sorties de l'extension grounded (acceptes en t, plus en t+1)   : ['p11', 'p12', 'p18', 'p8']
Lecture des cascades :
  - p15->p12 (non contré) fait sortir p12 ; p12 sortant, p9 n'est plus
    contré -> p9 entre ; et p9 dans l'extension défait p8 -> p7 entre
    (son attaquant p8 est desormais defait).
  - p8 sort : il exigeait la defaite de son attaquant p9, qui n'etait
    defait QUE par p12 -- p12 sortant, p8 perd sa defense.
  - p16->p11 fait sortir p11 (non contré).
  - p18 n'est plus entendu en t+1 : son attaque disparait (avec son noeud).
  - p1 reste dehors : son attaquant p2 n'est jamais defait (p3, seul
    attaquant de p2, n'entre jamais dans l'extension -- p4 indéfaisable).
E_ctrl = ['p10', 'p11', 'p12', 'p18', 'p3', 'p5', 'p8', 'p_ctrl']

attendu (ecrit avant)   : d_sem = 0.4, d_att = 0.1
mesure                  : d_sem = 0.400, d_att = 0.100

Controle positif : d_sem CONFORME, d_att CONFORME a l'attendu ecrit
p4 sorti : True | p2 sorti : True | p3 entre : True | p_ctrl entre : True
Resume de l'instrument (toute mesure d'ecart avec son plancher) :
  plancher_bruit : {'d_noeuds': 0.077, 'd_att': 0.111, 'd_sem': 0.125}
  ecart_inter_dates : {'d_noeuds': 0.2, 'd_att': 0.273, 'd_sem': 0.667}
  controle_positif : {'d_sem_attendu': 0.4, 'd_sem_mesure': 0.4, 'd_att_attendu': 0.1, 'd_att_mesure': 0.1}
  exclusions : {'t': {'p13': 'C1 : acte non assertif', 'p14': 'C3 : singleton (aucune relation dans la fenetre)'}, 't+1': {'p13': 'C1 : acte non assertif', 'p14': 'C3 : singleton (aucune relation dans la fenetre)', 'p17': 'C3 : singleton (aucune relation dans la fenetre)'}}
Lecture cumulative :
  G_cumul_t  : 13 arguments, 9 attaques
  G_cumul_t1 : 15 arguments, 11 attaques

Hypothese monotone sur ce corpus :
  args    G_cumul_t inclus dans G_cumul_t1 : True
  attacks G_cumul_t inclus dans G_cumul_t1 : True
  -> VERIFIEE PAR CONSTRUCTION : sur des corpus emboites, build_aif_graph
     n'exclut jamais un noeud deja relie (C3 ne frappe que les singletons, et
     les relations s'accumulent). Ici la monotonie est un theoreme de
     l'instrument ; elle ne le resterait pas devant un corpus reel retire des enonces.
Grounded de G_cumul_t (8/13) : ['p10', 'p11', 'p12', 'p18', 'p2', 'p4', 'p5', 'p8']
Hors grounded (7) : ['p1', 'p15', 'p16', 'p3', 'p6', 'p7', 'p9']

Retrait d'un argument DEFAIT (hors grounded) — effet mesure un par un :
  retrait 'p1': d_noeuds=0.133 d_att=0.182 d_sem=0.111  d_sem BOUGE (0.111)
  retrait 'p15': d_noeuds=0.067 d_att=0.091 d_sem=0.500  d_sem BOUGE (0.500)
  retrait 'p16': d_noeuds=0.067 d_att=0.091 d_sem=0.200  d_sem BOUGE (0.200)
  retrait 'p3': d_noeuds=0.067 d_att=0.182 d_sem=0.000  d_sem immobile
  retrait 'p6': d_noeuds=0.067 d_att=0.182 d_sem=0.000  d_sem immobile
  retrait 'p7': d_noeuds=0.067 d_att=0.182 d_sem=0.200  d_sem BOUGE (0.200)
  retrait 'p9': d_noeuds=0.133 d_att=0.182 d_sem=0.455  d_sem BOUGE (0.455)
  -> 2 retraits laissent grounded inchange ; 5 le font bouger
     (un defait qui ATTAQUAIT un candidat peut, en disparaisant, le faire accepter)

Retrait de 'p10' [ACCEPTE, dans grounded] :
  d_noeuds = 0.067   d_att = 0.000   d_sem = 0.111

Ce que l'instrument fait sous violation :
  d_noeuds > 0 pour TOUT retrait : la mesure structurelle voit chaque disparition ;
  d_sem ne bouge QUE si le retrait touche l'extension grounded (directement, ou via
      un attaquant disparu qui libere un candidat) ;
  AUCUNE des deux mesures ne dit RETRAIT vs APPARITION : le sens du mouvement n'est
  pas dans l'ecart de graphes — il est dans les evenements dates du corpus. C'est
  la limite explicite que l'hypothese monotone permettait de ne pas poser.
Exercice 1 a completer : fonction definie, a vous de l'appeler sur af_t / af_t1
Exercice 2 a completer : fonction definie, a vous de produire la distribution
Exercice 3 a completer : fonctions definies, a vous de produire le classement
[instrument] module importe : _dated_graphs_mod.py (Enonce, AIFGraph, build_aif_graph, to_dung_af, d_struct, d_sem, split_half)
# --- Garde : l'instrument est LA seule source de verite ---
# Pas de repli inline : une reimplementation locale deriverait de
# l'instrument et fausserait le renvoi ["rebranche, pas reecrit"].
# Si l'import a echoue, l'execution s'arrete ici, bruyamment.

if not INSTRUMENT_OK:
    raise RuntimeError("instrument indisponible : import _dated_graphs_mod.py echoue -- voir cellule 4")
# --- Fetch Arctic Shift : 4 corpus (bras x fenetre), stratifie mensuellement ---
# Chaque fenetre de 6 mois est decoupee en 6 seaux ~30 jours ; on tire par seau
# un nombre borne de posts. Parametres after/before en SECONDES EPOCH
# (teste : les chaines ISO y sont mal traitees et ramenent un seul jour,
# mesurable date_min==date_max -- defaut v1 corrige). Comptes par seau imprimes.

import urllib.request
import urllib.parse
import json
from datetime import date, timedelta

BASE = "https://arctic-shift.photon-reddit.com/api/posts/search"

WINDOWS = {
    "Bumble_pre":   ("Bumble",   date(2023, 10, 15), date(2024, 4, 29)),
    "Bumble_post":  ("Bumble",   date(2024, 5, 15), date(2024, 11, 15)),
    "Hinge_pre":    ("hingeapp", date(2023, 10, 15), date(2024, 4, 29)),
    "Hinge_post":   ("hingeapp", date(2024, 5, 15), date(2024, 11, 15)),
}
BUCKETS = 6
PER_BUCKET = 9          # jusqu'a ~54 posts / fenetre
BUCKET_COUNTS = {}


def _month_slices(a, b):
    slices = []
    span = (b - a).days
    step = span / max(BUCKETS, 1)
    for k in range(BUCKETS):
        start = a + timedelta(days=int(k * step))
        end = a + timedelta(days=int((k + 1) * step))
        slices.append((start, end))
    return slices


def _epoch(d):
    # Pas de strftime ("%s") sur Windows : epoch UTC calcule arithmetiquement.
    return (d - date(1970, 1, 1)).days * 86400


def fetch_page(sub, after, before, limit=100):
    qs = urllib.parse.urlencode({
        "subreddit": sub,
        "after": _epoch(after),
        "before": _epoch(before),
        "limit": limit, "sort": "desc", "sort_type": "created_utc",
    })
    url = f"{BASE}?{qs}"
    req = urllib.request.Request(url, headers={"User-Agent": "observatoire-cas1/1.0"})
    with urllib.request.urlopen(req, timeout=30) as r:
        body = json.load(r)
    return body.get("data", [])


def fetch_corpus(sub, win_start, win_end, per_bucket=PER_BUCKET):
    rows, per_bucket_counts = [], []
    for (s, e) in _month_slices(win_start, win_end):
        page = fetch_page(sub, s + timedelta(days=1), e + timedelta(days=1))
        rows += page[:per_bucket]
        per_bucket_counts.append(len(page[:per_bucket]))
    BUCKET_COUNTS[f"{sub}_{win_start}"] = per_bucket_counts
    return rows


corpora = {}
for name, (sub, a, b) in WINDOWS.items():
    rows = fetch_corpus(sub, a, b)
    corpora[name] = rows
    n_kept = sum(1 for r in rows if r.get("selftext") or r.get("title"))
    print(f"  {name}: {len(rows)} posts, {n_kept} non vides | buckets={BUCKET_COUNTS[f'{sub}_{a}']}")

total = sum(len(c) for c in corpora.values())
print(f"\nTotal brut : {total} posts. Aucun n'est commite en memoire d'execution.")
  Bumble_pre: 54 posts, 54 non vides | buckets=[9, 9, 9, 9, 9, 9]
  Bumble_post: 54 posts, 54 non vides | buckets=[9, 9, 9, 9, 9, 9]
  Hinge_pre: 54 posts, 54 non vides | buckets=[9, 9, 9, 9, 9, 9]
  Hinge_post: 54 posts, 54 non vides | buckets=[9, 9, 9, 9, 9, 9]

Total brut : 216 posts. Aucun n'est commite en memoire d'execution.
# --- Agregats public-safe : pas de selftext brut, pas d'auteur ---
# Pour chaque corpus : nb de posts, longueur moyenne, etendue des dates
# (sanity de fenetre : les bornes doivent couvrir la periode pre-inscrite).
# Le selftext complet n'est JAMAIS ecrit dans le notebook committe.

import re
import json as _json
import datetime as _dt


def anonymize(text):
    if not text:
        return ""
    t = re.sub(r"https?://\S+", "", text)
    t = re.sub(r"/u/\w+|@\w+", "[REDACTED]", t)
    t = t.strip()
    return (t[:140] + "...") if len(t) > 140 else t


def iso(ts):
    return _dt.datetime.fromtimestamp(ts, tz=_dt.timezone.utc).strftime("%Y-%m-%d")


aggregats = {}
for name, rows in corpora.items():
    lengths = [len((r.get("selftext") or "") + (r.get("title") or "")) for r in rows]
    dates = [r["created_utc"] for r in rows if r.get("created_utc")]
    verbatims = [anonymize(r.get("selftext") or r.get("title", ""))
                 for r in rows if (r.get("selftext") or r.get("title"))]
    verbatims = [v for v in verbatims if v][:5]
    aggregats[name] = {
        "n": len(rows),
        "len_moy": round(sum(lengths) / max(len(lengths), 1), 1),
        "date_min": iso(min(dates)) if dates else None,
        "date_max": iso(max(dates)) if dates else None,
        "verbatims": verbatims,
    }

meta = {k: {kk: vv for kk, vv in v.items() if kk != "verbatims"}
        for k, v in aggregats.items()}
print(_json.dumps(meta, indent=2, ensure_ascii=False))
{
  "Bumble_pre": {
    "n": 54,
    "len_moy": 226.6,
    "date_min": "2023-11-16",
    "date_max": "2024-04-29"
  },
  "Bumble_post": {
    "n": 54,
    "len_moy": 326.9,
    "date_min": "2024-06-14",
    "date_max": "2024-11-15"
  },
  "Hinge_pre": {
    "n": 54,
    "len_moy": 240.6,
    "date_min": "2023-11-16",
    "date_max": "2024-04-29"
  },
  "Hinge_post": {
    "n": 54,
    "len_moy": 130.1,
    "date_min": "2024-06-14",
    "date_max": "2024-11-15"
  }
}
# --- Extraction LLM Ollama : categories + oppositions, ECHANTILLON COMPLET ---
# Modele epingle : qwen2.5:7b-instruct-q4_K_M (4.7 Go).
# Temperature 0, prompt + schema JSON e pingles. ~50 textes / cellule,
# 4 cellules = ~200 classifications (duree 15-40 min, progression toutes les
# 10). Grille de lecture en bas de cellule.

import urllib.request
import json

OLLAMA = "http://localhost:11434/api/generate"
MODEL = "qwen2.5:7b-instruct-q4_K_M"

CATEGORIES = [
    "INIT_NORM_W",         # la femme doit initier
    "INIT_NORM_ANY",       # n'importe qui peut initier
    "INIT_COST",           # cout/burden de l'initiation
    "BLAME",               # blame d'un genre pour la non-initiation
    "MATCH_DECAY",         # matchs meurent sans premier message
    "OPENING_MOVE_REACT",  # reaction au changement nomme
]

PROMPT_TEMPLATE = """Classifie le post ci-dessous. Renvoie UNIQUEMENT un objet JSON :
{{ "cats": [categories portees, 0..n], "opposes": [categories AUXQUELLES le post s'oppose, 0..n] }}.

Categories possibles :
{cats}

Regle : "opposes" ne contient que des categories que le post contredit ou refute
explicitement. Pas d'inference : absence de mention = pas d'opposition.

Post :
{post}
"""


def classify(text, retries=3):
    prompt = PROMPT_TEMPLATE.format(cats=", ".join(CATEGORIES), post=text[:600])
    payload = json.dumps({
        "model": MODEL, "prompt": prompt, "stream": False,
        "options": {"temperature": 0.0, "num_predict": 250},
    }).encode("utf-8")
    req = urllib.request.Request(OLLAMA, data=payload,
                                 headers={"Content-Type": "application/json"})
    for attempt in range(retries):
        try:
            with urllib.request.urlopen(req, timeout=180) as r:
                resp = json.load(r)
            txt = resp.get("response", "").strip()
            i, j = txt.find("{"), txt.rfind("}")
            if i >= 0 and j > i:
                obj = json.loads(txt[i:j + 1])
                obj.setdefault("cats", [])
                obj.setdefault("opposes", [])
                valid = [c for c in obj["cats"] if c in CATEGORIES]
                valid_o = [c for c in obj["opposes"] if c in CATEGORIES]
                return {"cats": valid, "opposes": valid_o}
        except Exception:
            continue
    return {"cats": [], "opposes": [], "error": "3 echecs"}


classifications = {}
fails = 0
for name, rows in corpora.items():
    eligible = [r for r in rows if (r.get("selftext") or r.get("title"))]
    classifications[name] = []
    print(f"\n  extraction : {name} ({len(eligible)} textes)")
    for k, r in enumerate(eligible):
        text = r.get("selftext") or r.get("title", "")
        out = classify(text)
        if "error" in out:
            fails += 1
        classifications[name].append({"id": r.get("id"), **out})
        if k % 10 == 9:
            print(f"    ... {k + 1}/{len(eligible)}")

total = sum(len(v) for v in classifications.values())
print(f"\nTotal classifications : {total} | echecs apres retries : {fails}")

  extraction : Bumble_pre (54 textes)
    ... 10/54
    ... 20/54
    ... 30/54
    ... 40/54
    ... 50/54

  extraction : Bumble_post (54 textes)
    ... 10/54
    ... 20/54
    ... 30/54
    ... 40/54
    ... 50/54

  extraction : Hinge_pre (54 textes)
    ... 10/54
    ... 20/54
    ... 30/54
    ... 40/54
    ... 50/54

  extraction : Hinge_post (54 textes)
    ... 10/54
    ... 20/54
    ... 30/54
    ... 40/54
    ... 50/54

Total classifications : 216 | echecs apres retries : 0
# --- Distribution des categories par cellule ---
# Comptage : combien de textes portent >= 1 occurrence de la categorie.
# Multilabel : un texte peut porter plusieurs categories.

from collections import Counter
import pandas as pd

dist = {}
for name, rows in classifications.items():
    c = Counter()
    for r in rows:
        for cat in r.get("cats", []):
            c[cat] += 1
    dist[name] = dict(c)

df_dist = pd.DataFrame(dist, index=CATEGORIES).fillna(0).astype(int)
print("Distribution (count de textes portant la categorie) :")
print(df_dist)

delta_t = df_dist["Bumble_post"]  - df_dist["Bumble_pre"]
delta_c = df_dist["Hinge_post"]   - df_dist["Hinge_pre"]
delta_T = df_dist["Bumble_post"]  - df_dist["Hinge_post"]
delta_B = df_dist["Bumble_pre"]   - df_dist["Hinge_pre"]
did = delta_T - delta_B
print("\nDelta post-pre traite (Bumble)  :", delta_t.to_dict())
print("Delta post-pre controle (Hinge) :", delta_c.to_dict())
print("Delta DiD (traite - controle)   :", did.to_dict())


# --- Distribution des oppositions par cellule (fix #18858) ---
# Compte de textes s'opposant explicitement a chaque categorie cible,
# par cellule. Rend la moitie "attaques" du verdict (comptes CA, DiD
# attaques, plancher attaques) auditable depuis l'artefact : jusqu'ici
# `opposes` n'apparaissait dans aucune sortie committe.
opp = {}
for name, rows in classifications.items():
    c = Counter()
    for r in rows:
        for o in r.get("opposes", []):
            c[o] += 1
    opp[name] = dict(c)

df_opp = pd.DataFrame(opp, index=CATEGORIES).fillna(0).astype(int)
print("\nOppositions (count de textes s'opposant a la categorie cible) :")
print(df_opp)
Distribution (count de textes portant la categorie) :
                    Bumble_pre  Bumble_post  Hinge_pre  Hinge_post
INIT_NORM_W                 40           40         46          46
INIT_NORM_ANY               12           12          7           7
INIT_COST                    2            9          2           1
BLAME                        0            2          0           0
MATCH_DECAY                  4            2          2           0
OPENING_MOVE_REACT           0            0          0           0

Delta post-pre traite (Bumble)  : {'INIT_NORM_W': 0, 'INIT_NORM_ANY': 0, 'INIT_COST': 7, 'BLAME': 2, 'MATCH_DECAY': -2, 'OPENING_MOVE_REACT': 0}
Delta post-pre controle (Hinge) : {'INIT_NORM_W': 0, 'INIT_NORM_ANY': 0, 'INIT_COST': -1, 'BLAME': 0, 'MATCH_DECAY': -2, 'OPENING_MOVE_REACT': 0}
Delta DiD (traite - controle)   : {'INIT_NORM_W': 0, 'INIT_NORM_ANY': 0, 'INIT_COST': 8, 'BLAME': 2, 'MATCH_DECAY': 0, 'OPENING_MOVE_REACT': 0}

Oppositions (count de textes s'opposant a la categorie cible) :
                    Bumble_pre  Bumble_post  Hinge_pre  Hinge_post
INIT_NORM_W                  0            1          0           0
INIT_NORM_ANY                0            1          0           0
INIT_COST                    0            1          2           0
BLAME                        0            2          0           0
MATCH_DECAY                  0            1          0           0
OPENING_MOVE_REACT           0            0          0           0
# --- Mesure structurelle via l'instrument (module _dated_graphs_mod) ---
# Unite d'analyse PRIMAIRE : position argumentative (la categorie portee par
# un texte). Chaque (texte, categorie portee) est un enonce assertif ; un lien
# CA (X -> Y) existe des qu'un texte porte X et s'oppose explicitement a Y.
# Projection Dung (to_dung_af) puis d_struct/d_sem de l'instrument, compares
# en DiD traite - controle.
# Niveau TEXTUEL : mesure puis saturation -- les textes sont uniques (aucun
# repost), donc props disjointes entre fenetres et d_struct = 1.0 par
# construction. Imprime comme saturation mesuree ; non utilise pour le verdict.
# ADAPTATION DOCUMENTEE du critere C3 l'instrument #13310 (build_aif_graph
# exclut les propositions sans relation "singleton", concu pour des corpus
# denses a propositions repetees). Ici l'unite est la CATEGORIE (position) :
# exclure les singletons viderait la mesure (mesure v1 : 0 I-nodes cote
# controle, mesuree puis imprimee). C3 neutralise et imprime comme divergence
# assumee ; seules les exclusions C1 subsistent.

import hashlib


def text_to_prop(text):
    h = hashlib.md5(text.encode("utf-8")).hexdigest()[:6]
    return f"p{h}"


def build_enonces(rows, unit, day_start):
    """Enonces AIF pour une fenetre.
    unit="cat" (primaire)  : un enonce assertif par (texte, categorie portee),
        relations CA = categories explicitement opposees par ce texte.
    unit="text" (v1, sature): un enonce par texte (prop = hash), relations CA
        vers les propositions portant une categorie opposee.
    """
    cat_to_props = {}
    for r in rows:
        for cat in r.get("cats", []):
            cat_to_props.setdefault(cat, set()).add(r["id"])
    enonces = []
    for i, r in enumerate(rows):
        day = day_start + i
        opposes = set(r.get("opposes", []))
        cats = r.get("cats", []) if unit == "cat" else [None]
        for cat in cats:
            if unit == "cat":
                prop = cat
                rels = [("CA", o) for o in opposes if o != cat]
            else:
                prop = text_to_prop(r.get("verbatim") or r.get("id", ""))
                rels = []
                for o in opposes:
                    for cible_id in cat_to_props.get(o, ()):
                        if cible_id != r["id"]:
                            rels.append(("CA", text_to_prop(cible_id)))
            enonces.append(Enonce(day, prop, "assertif", tuple(sorted(set(rels)))))
    return enonces


def build_graph_no_c3(enonces):
    # build_aif_graph de l'instrument avec C3 desactive (adaptation
    # documentee ci-dessus) : seules les exclusions C1 subsistent.
    g = AIFGraph()
    vues = set()
    for e in enonces:
        if e.prop in vues:
            continue
        vues.add(e.prop)
        if e.acte != "assertif":
            g.excluded.append((e.prop, "C1 : acte non assertif"))
    exclus = {p for p, _ in g.excluded}
    for p in sorted(vues - exclus):
        g.i_nodes[p] = f"position {p}"
    for e in enonces:
        for scheme, cible in e.relations:
            g.scheme_nodes[f"{scheme}_{e.prop}_{cible}"] = (scheme, e.prop, cible)
    return g


# --- Niveau TEXTUEL : saturation mesuree (regression v1, C3 neutralise) ---
g_text = {}
af_text = {}
for name in ("Bumble_pre", "Bumble_post", "Hinge_pre", "Hinge_post"):
    period = "post" if "_post" in name else "pre"
    enonces = build_enonces(classifications[name], "text", 1 if period == "pre" else 200)
    gg = build_graph_no_c3(enonces)
    g_text[name] = gg
    af_text[name] = to_dung_af(gg)
    print(f"  [texte] {name} : {len(gg.i_nodes)} I-nodes, {len(gg.scheme_nodes)} CA, "
          f"{len(gg.excluded)} exclus (C1)")

db_t = (d_struct(af_text["Bumble_pre"], af_text["Bumble_post"]),
        d_sem(af_text["Bumble_pre"], af_text["Bumble_post"]))
dh_t = (d_struct(af_text["Hinge_pre"], af_text["Hinge_post"]),
        d_sem(af_text["Hinge_pre"], af_text["Hinge_post"]))
print("\n  [texte] d_struct(Bumble)={:.3f}/{:.3f} d_sem={:.3f} | "
      "d_struct(controle)={:.3f}/{:.3f} d_sem={:.3f}".format(
          db_t[0][0], db_t[0][1], db_t[1], dh_t[0][0], dh_t[0][1], dh_t[1]))
print("  SATURATION : textes uniques par fenetre -> props disjointes -> "
      "d_struct=1.0 par construction. Niveau textuel non discriminant ici ; "
      "non utilise pour le verdict (transition v1->v2, cf. §6).")

# --- Niveau POSITION (categorie portee) : mesure primaire ---
g_cat = {}
af_cat = {}
for name in ("Bumble_pre", "Bumble_post", "Hinge_pre", "Hinge_post"):
    period = "post" if "_post" in name else "pre"
    enonces = build_enonces(classifications[name], "cat", 1 if period == "pre" else 200)
    gg = build_graph_no_c3(enonces)
    g_cat[name] = gg
    af_cat[name] = to_dung_af(gg)
    print(f"  [position] {name} : {len(gg.i_nodes)} I-nodes, {len(gg.scheme_nodes)} CA, "
          f"{len(gg.excluded)} exclus (C1)")

db = (d_struct(af_cat["Bumble_pre"], af_cat["Bumble_post"]),
      d_sem(af_cat["Bumble_pre"], af_cat["Bumble_post"]))
dh = (d_struct(af_cat["Hinge_pre"], af_cat["Hinge_post"]),
      d_sem(af_cat["Hinge_pre"], af_cat["Hinge_post"]))
delta_n = db[0][0] - dh[0][0]
delta_a = db[0][1] - dh[0][1]
delta_s = db[1] - dh[1]
print(f"\n  [position] d_struct(Bumble)   : noeuds={db[0][0]:.3f} attaques={db[0][1]:.3f} d_sem={db[1]:.3f}")
print(f"  [position] d_struct(controle) : noeuds={dh[0][0]:.3f} attaques={dh[0][1]:.3f} d_sem={dh[1]:.3f}")
print(f"  DiD(noeuds)   = {delta_n:.3f}")
print(f"  DiD(attaques) = {delta_a:.3f}")
print(f"  DiD(sem)      = {delta_s:.3f}")
  [texte] Bumble_pre : 54 I-nodes, 0 CA, 0 exclus (C1)
  [texte] Bumble_post : 54 I-nodes, 66 CA, 0 exclus (C1)
  [texte] Hinge_pre : 54 I-nodes, 3 CA, 0 exclus (C1)
  [texte] Hinge_post : 54 I-nodes, 0 CA, 0 exclus (C1)

  [texte] d_struct(Bumble)=1.000/1.000 d_sem=1.000 | d_struct(controle)=1.000/1.000 d_sem=1.000
  SATURATION : textes uniques par fenetre -> props disjointes -> d_struct=1.0 par construction. Niveau textuel non discriminant ici ; non utilise pour le verdict (transition v1->v2, cf. §6).
  [position] Bumble_pre : 4 I-nodes, 0 CA, 0 exclus (C1)
  [position] Bumble_post : 5 I-nodes, 9 CA, 0 exclus (C1)
  [position] Hinge_pre : 4 I-nodes, 2 CA, 0 exclus (C1)
  [position] Hinge_post : 3 I-nodes, 0 CA, 0 exclus (C1)

  [position] d_struct(Bumble)   : noeuds=0.200 attaques=1.000 d_sem=1.000
  [position] d_struct(controle) : noeuds=0.250 attaques=1.000 d_sem=0.500
  DiD(noeuds)   = -0.050
  DiD(attaques) = 0.000
  DiD(sem)      = 0.500
# --- Plancher de bruit : split-half CHRONOLOGIQUE (instrument) ---
# Le split_half de l'instrument decoupe chaque periode en deux moities par
# ordre d'arrivee (premiere / seconde), sans seed : une seule realisation
# par bras, fidele a #13310. Memes regles que la cellule 10 : unite POSITION,
# C3 neutralise (adaptation documentee). Le DiD observe doit etre lu contre
# ce plancher.

def split_and_d_struct(enonces):
    moitie_a, moitie_b = split_half(enonces)
    ga, gb = build_graph_no_c3(moitie_a), build_graph_no_c3(moitie_b)
    aa, ab = to_dung_af(ga), to_dung_af(gb)
    return d_struct(aa, ab), d_sem(aa, ab)


floor_b, floor_sb = split_and_d_struct(
    build_enonces(classifications["Bumble_pre"], "cat", 1))
floor_h, floor_sh = split_and_d_struct(
    build_enonces(classifications["Hinge_pre"], "cat", 1))

floor_n = (floor_b[0] + floor_h[0]) / 2
floor_a = (floor_b[1] + floor_h[1]) / 2
print(f"Plancher noeuds   : Bumble pre={floor_b[0]:.3f} Hinge pre={floor_h[0]:.3f} moyen={floor_n:.3f}")
print(f"Plancher attaques : Bumble pre={floor_b[1]:.3f} Hinge pre={floor_h[1]:.3f} moyen={floor_a:.3f}")
print(f"Plancher sem      : Bumble pre={floor_sb:.3f} Hinge pre={floor_sh:.3f}")

above_floor_n = delta_n - floor_n
above_floor_a = delta_a - floor_a
print(f"\nDiD observe (noeuds)   {delta_n:+.3f} ; DiD - plancher : {above_floor_n:+.3f} ; critere signe : > +0.050 (gate : DiD > plancher + 0.05)")
print(f"DiD observe (attaques) {delta_a:+.3f} ; DiD - plancher : {above_floor_a:+.3f} ; critere signe : > +0.050")

VERDICT = "INCONCLUSIVE"
REASONS = []
if above_floor_n > 0.05:
    REASONS.append("DiD(noeuds) > plancher + 0.05")
if above_floor_a > 0.05:
    REASONS.append("DiD(attaques) > plancher + 0.05")

h1_signed = (
    delta_t.get("INIT_NORM_ANY", 0) > 0
    and delta_t.get("OPENING_MOVE_REACT", 0) > 0
    and delta_t.get("INIT_NORM_W", 0) <= 0
)
if h1_signed:
    REASONS.append("signes delta_traite coherents H1 "
                   "(INIT_NORM_ANY+, OPENING_MOVE_REACT+, INIT_NORM_W-)")

if above_floor_n > 0.05 and above_floor_a > 0.05 and h1_signed:
    VERDICT = "EXPLORATION"
elif above_floor_n <= 0.05 and above_floor_a <= 0.05:
    VERDICT = "INCONCLUSIVE"
else:
    VERDICT = "TRIANGULATION"

print(f"\nVERDICT : {VERDICT}")
for r in REASONS:
    print(f"  raison : {r}")
Plancher noeuds   : Bumble pre=0.000 Hinge pre=0.250 moyen=0.125
Plancher attaques : Bumble pre=0.000 Hinge pre=1.000 moyen=0.500
Plancher sem      : Bumble pre=0.000 Hinge pre=0.000

DiD observe (noeuds)   -0.050 ; DiD - plancher : -0.175 ; critere signe : > +0.050 (gate : DiD > plancher + 0.05)
DiD observe (attaques) +0.000 ; DiD - plancher : -0.500 ; critere signe : > +0.050

VERDICT : INCONCLUSIVE

4. Verdict et plafond de preuve

À la fin du notebook, la cellule précédente pose le verdict sur quatre observations conjointes :

  1. Plancher split-half : split chronologique (instrument) sur la fenêtre pré de chaque bras — unité position (catégorie argumentative).
  2. DiD(noeuds) : Δ_struct(Bumble) − Δ_struct(contrôle).
  3. DiD(attaques) : idem sur les schemes CA.
  4. Signes H₁ : direction signée par catégorie (cohérence avec le prédict ).

Verdict : EXPLORATION, TRIANGULATION ou INCONCLUSIVE. Jamais plus haut qu’associatif (barreau 1) ; barreau 2 atteignable seulement si tous les critères structurels sont satisfaits. Barreau 3 récusé par construction (#13309 §1).

5. Biais de sélection et plafond de barreau

r/Bumble sur-représente les utilisateurs investis et les événements saillants. Le biais de saillance pousse vers H₁ : la rupture elle-même fabrique des posts OPENING_MOVE_REACT. Le bras contrôle (r/hingeapp) n’absorbe pas ce biais car il ne porte aucune rupture d’initiation sur la fenêtre. Le contrôle DiD reste imparfait : il absorbe le biais de plateforme (Reddit) et de fenêtre, pas le biais de sujet.

Plafond revendiqué d’avance : barreau associatif. Quasi-expérimental inatteignable avec ce corpus (manquent : utilisateurs silencieux, résiliés, hors-app, non-anglophones).

6. Limites assumées et extensions futures

  • Volume : ~50 textes / cellule (stratification mensuelle effective en 6 seaux, comptes par seau imprimés). Suffisant pour un premier signal, insuffisant pour une robustesse statistique complète : l’extension à ~200-500/cellule est une issue dédiée.
  • Unité d’analyse : la position argumentative (catégorie portée) est l’unité primaire — c’est elle que déplace une rupture de norme. Le niveau textuel sature : textes uniques par fenêtre → propositions disjointes → d_struct = 1.0 par construction (mesuré et imprimé, cellule 10). La mesure v1 ajoutait le critère C3 (singletons exclus) : toutes les fenêtres se vidaient (0 I-nodes, mesuré) et le verdict TRIANGULATION imprimé était un artefact ; v2 neutralise C3 — divergence unique, documentée et imprimée.
  • Instrument rebranché par import (_dated_graphs_mod.py, conversion officielle de #13310) : d_struct/d_sem sur projection Dung, split_half chronologique — à l’exception de C3 (documentée ci-dessus), aucune réécriture.
  • Liens CA = oppositions explicites (champ opposes du prompt LLM épinglé) : pas d’heuristique de taille sur les catégories.
  • Modèle local Ollama : re-exécution à température 0 et prompt épinglé — déterministe à version d’Ollama et blob identiques ; mesuré (v3, 02/10, Ollama 0.35.0) : les agrégats de classification varient légèrement d’une version d’Ollama à l’autre (DiD(attaques) +0.250 → 0.000, plancher de bruit inchangé), le verdict INCONCLUSIVE est stable. Modèle qwen2.5:7b-instruct-q4_K_M épinglé par nom, toute mise à jour = PR dédiée.
  • Chute Hinge_post 53 → 0 CA (bras contrôle) : les comptes bruts par bras sont imprimés en cellule 10. Au niveau texte, les quatre cellules portent 54 I-nodes et 0 exclusion C1 — l’extraction n’a ni changé de volume ni vidé une cellule. Au niveau position, Hinge_pre = 2 CA et Hinge_post = 0 CA, ce qui concorde avec la table Distribution des oppositions (2 oppositions INIT_COST en pré, 0 en post). La chute est donc portée par la re-classification (quelles catégories s’opposent), non par une casse de parsing ni par le filtre C1 : les deux niveaux de mesure et la table d’oppositions le disent indépendamment.

7. Conclusion

Premier cas de l’Observatoire livré. Instrument Dated_Graphs rebranché, pas réécrit (à l’exception de la neutralisation C3, divergence unique assumée et documentée cellule 10 + amendement de pré-inscription n°2 — l’unité position rend les « singletons » structurels). Rupture datée, fenêtres symétriques, bras contrôle intact.

Verdict mesuré (v2, 2026-09-16 ; re-mesuré v3, 2026-10-02) : INCONCLUSIVE — DiD(noeuds) = −0.050 pour un plancher split-half de 0.125, DiD(attaques) = 0.000 (v2 : +0.250 — les agrégats de classification varient légèrement selon la version d’Ollama, cf. §6) pour un plancher de 0.500 : aucun des deux ne dépasse plancher + 0.05, et la signature OPENING_MOVE_REACT est absente du corpus (0 posts dans les deux bras). Le niveau textuel sature (textes uniques → d_struct = 1.0 par construction, mesuré) ; le signal positionnel est sous le plancher de bruit. La rupture a été datée et mesurée ; la structure argumentative ne bouge pas au-delà du bruit de la fenêtre — c’est un résultat négatif net, cohérent avec la prudence du barreau associatif.

Voir aussi :

  • Argumentation-Obs-01-Graphes-Dates-Python.ipynb (#13310, l’instrument)
  • Issue #13303 (EPIC Observatoire)
  • Issue #13309 (protocole de preuve)
  • Commentaire d’inscription #16410 (pré-inscription datée, axes signés) + amendements v1→v2 de la pré-inscription
Retour au sommet