← Toutes les capacités

02 / COUNTERFACTUAL

Mondes hypothétiques parallèles

Explorez plusieurs explications incompatibles sur une base de faits commune, sans transformer une hypothèse séduisante en vérité canonique.

RUST CORE
01

Pourquoi cette capacité

Brancher les hypothèses, pas les faits

Les mondes partagent des faits de base immuables. Chaque branche ajoute hypothèses, prédictions, contradictions et preuves attendues. Les comparaisons cherchent l’observation qui discrimine réellement les branches.

02

Scénario

FIELD NOTE / SCENARIO

Départager une fuite interne d’un fournisseur compromis

Deux équipes expliquent le même exfiltrat différemment. Elles conservent leurs branches, prédisent des traces différentes et comparent l’impact du retrait d’une source fragile.

  1. 01

    Créer deux mondes sur la même base factuelle

  2. 02

    Attacher prédictions et preuves contradictoires par branche

  3. 03

    Fusionner ou écarter avec une décision auditée

Résultat attendu

Le système garde le désaccord ouvert tant qu’aucune preuve discriminante ne justifie une résolution.

03

Jeu de données synthétique

Deux explications, cinq faits partagés, une observation qui ne tranche rien

42 Go quittent un réseau de conception un dimanche soir, via le VPN fournisseur, avec un compte de service partagé, sans alerte. Un employé a exfiltré le dépôt, ou l’environnement du fournisseur a été compromis et le compte rejoué. Les deux explications collent à tous les faits établis. Le jeu de données sépare trois choses que les pipelines confondent : les faits de base, partagés et immuables, les hypothèses, propres à une branche, et les prédictions, propres à une branche et testables.

18Mémoires
30Relations
5Faits de base partagés
2Mondes
5Prédictions
1Observations non discriminantes
Télécharger le jeu de donnéesdataset.json · JSON · 28 kB
04

Pourquoi Corrobore

Choisir tôt l’explication la plus plausible est l’erreur qui coûte cher.

Ce qu’il faut construire soi-même

  • Retenir une seule explication fait disparaître l’autre. Quand la preuve décisive tombe trois semaines plus tard, il n’y a plus de branche à réactiver, seulement une analyse à refaire.
  • Dupliquer le graphe par hypothèse laisse les branches diverger sur les faits eux-mêmes. Corriger un fait de base ne se propage plus, et chaque branche finit avec sa version privée du réel.
  • Sans prédictions explicites, rien ne distingue une preuve qui départage d’une preuve qui rassure. On collecte ce qui confirme, et le compte de service partagé ressemble à un progrès.

Ce que le contrat garantit

  • Les branches se posent sur les mêmes enregistrements de faits : dans le notebook, les deux mondes assument exactement les cinq mêmes faits de base, aucun n’a de version privée.
  • Les prédictions sont des enregistrements, donc « qu’est-ce qu’on devrait observer ? » devient interrogeable au lieu d’être une phrase dans un rapport.
  • Mener n’est pas résoudre. Clore le dossier en effaçant la branche perdante est refusé par POLICY_APPROVAL_REQUIRED, comme toute consolidation destructive.
05

Notebook exécutable

Quinze cellules qui refusent de trancher trop tôt

Le notebook vérifie que les deux branches partagent les mêmes faits de base, liste ce que chacune prédit, puis identifie les observations réellement discriminantes. Le piège est explicite : le compte de service partagé est vrai, pertinent, prédit par les deux mondes, et ne sépare rien. En ne comptant que les preuves discriminantes, la branche fournisseur passe devant. Le notebook tente alors de clore en effaçant la branche perdante, se fait refuser, et termine par un contrefactuel : sans l’enregistrement d’ASN, l’avance disparaît.

parallel-worlds/notebook.ipynbEXTRAIT / 16 CELLULES
MARKDOWN [0]

Reproduisez le scénario sur une copie de travail et conservez les identifiants de preuves, de session et de snapshot dans le résultat.

CODE [1] / PYTHON
import json, os, pathlib, time
import requests
import urllib3

# The quick-start server uses a self-signed certificate; disable verification
# for the local playbook only, never against a real deployment.
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

BASE_URL = os.environ.get('CORROBORE_URL', 'https://127.0.0.1:8080')
TOKEN = os.environ.get('CORROBORE_HTTP_AUTH_TOKEN', 'change-me')

http = requests.Session()
http.verify = False
http.headers.update({'Authorization': f'Bearer {TOKEN}', 'Content-Type': 'application/json'})


class MemoryError_(RuntimeError):
    """Carries the stable v1 error taxonomy instead of a bare HTTP status."""

    def __init__(self, code, message, status):
        super().__init__(f'{code}: {message}')
        self.code, self.message, self.status = code, message, status


def memory_op(operation, payload, idempotency_key=None, expect_error=False):
    """POST /v1/memory/operations and unwrap the typed result."""
    body = {'contract_version': 'v1', 'operation': operation, 'input': payload}
    if idempotency_key is not None:
        body['idempotency_key'] = idempotency_key
    for attempt in range(12):
        response = http.post(f'{BASE_URL}/v1/memory/operations', data=json.dumps(body))
        # Protected routes share a global token bucket (50 rps sustained, 200 burst by
        # default) that a bulk load will hit. Two details matter here: the 429 body is
        # plain text, not the JSON error envelope, and `Retry-After` can be `0` — so
        # honour it as a floor, never as the whole wait.
        if response.status_code == 429:
            hinted = float(response.headers.get('Retry-After', 0) or 0)
            time.sleep(max(hinted, 0.2 * (attempt + 1)))
            continue
        break
    if response.status_code != 200:
        try:
            error = response.json().get('error', {})
        except ValueError:  # 429 and other transport rejections are not JSON
            error = {}
        failure = MemoryError_(error.get('code', 'UNKNOWN'), error.get('message', response.text), response.status_code)
        if expect_error:
            return failure
        raise failure
    if expect_error:
        raise AssertionError(f'{operation} unexpectedly succeeded')
    return response.json()['result']['result']


ready = http.get(f'{BASE_URL}/health/ready').json()
version = http.get(f'{BASE_URL}/version').json()
print('ready  :', json.dumps(ready)[:160])
print('version:', json.dumps(version)[:160])
CODE [2] / PYTHON
assumed = {}
for edge in edges:
    if edge['kind'] == 'assumes':
        assumed.setdefault(records[edge['source_id']]['identity_key'], set()).add(
            records[edge['target_id']]['identity_key'])

worlds = sorted(assumed)
print('worlds:', worlds)
for name in worlds:
    print(f'  {name} assumes {len(assumed[name])} base facts')

shared = set.intersection(*assumed.values())
print()
print('base facts shared by both branches:', len(shared))
for fact in sorted(shared):
    print(f'  {fact}')

assert len(shared) == dataset['facts']['shared_base_facts']
assert all(assumed[name] == shared for name in worlds), 'neither branch has private facts'
CODE [3] / PYTHON
predicts = {}
for edge in edges:
    if edge['kind'] == 'predicts':
        predicts.setdefault(records[edge['source_id']]['identity_key'], set()).add(
            records[edge['target_id']]['identity_key'])

for name in worlds:
    print(name)
    for prediction in sorted(predicts[name]):
        print(f'    {prediction}')

both = predicts[worlds[0]] & predicts[worlds[1]]
print()
print('predicted by BOTH branches:', sorted(both))
print('    testing these can never separate the two explanations')
CODE [4] / PYTHON
outcomes = {}
for edge in edges:
    if edge['kind'] in ('confirms', 'refutes'):
        observation = records[edge['source_id']]['identity_key']
        outcomes.setdefault(observation, []).append(
            (edge['kind'], records[edge['target_id']]['identity_key']))


def separates(observation):
    """An observation discriminates when its effects fall on different branches."""
    touched = set()
    for _, prediction in outcomes.get(observation, []):
        touched |= {w for w in worlds if prediction in predicts[w]}
    exclusive = {p for _, p in outcomes.get(observation, []) if p not in both}
    return len(touched) > 1 and bool(exclusive)


print(f"{'observation':<34}{'discriminates':>14}   effects")
print('-' * 92)
for observation in sorted(outcomes):
    effects = ', '.join(f'{kind} {prediction}' for kind, prediction in sorted(outcomes[observation]))
    print(f'{observation:<34}{str(separates(observation)):>14}   {effects}')

trap = 'observation--account-shared'
assert separates(trap) is False, 'both worlds predicted the shared account'
assert separates('observation--asn-mismatch') is True
CODE [5] / PYTHON
def score_worlds(exclude_non_discriminating=True):
    tally = {name: {'confirmed': 0, 'refuted': 0} for name in worlds}
    for observation, effects in outcomes.items():
        if exclude_non_discriminating and not separates(observation):
            continue
        for kind, prediction in effects:
            for name in worlds:
                if prediction in predicts[name]:
                    tally[name]['confirmed' if kind == 'confirms' else 'refuted'] += 1
    return tally


for label, strict in (('all observations', False), ('discriminating only', True)):
    tally = score_worlds(strict)
    print(label)
    for name in worlds:
        print(f"    {name:<20} confirmed {tally[name]['confirmed']}  refuted {tally[name]['refuted']}")

strict = score_worlds(True)
leader = max(worlds, key=lambda w: strict[w]['confirmed'] - strict[w]['refuted'])
print()
print('branch ahead on discriminating evidence:', leader)
assert leader == 'world--supplier'
CODE [6] / PYTHON
world_ids = [ids[name] for name in worlds]
CONSOLIDATION = {
    'memory_ids': world_ids,
    'canonical_id': ids[leader],
    'reason': 'supplier branch leads on discriminating evidence; insider branch retained',
    'preserve_disagreements': True,
}

proposal = memory_op('consolidate', dict(CONSOLIDATION, mode={'mode': 'propose'}))
print('proposal :', proposal['proposal_id'])
print('applied  :', proposal['applied'], '(a proposal changes nothing)')

erased = memory_op('consolidate', dict(CONSOLIDATION,
                                       mode={'mode': 'apply_approved',
                                             'proposal_id': proposal['proposal_id'],
                                             'approval_policy': 'policy--lead-analyst'},
                                       preserve_disagreements=False),
                   idempotency_key='pw:erase', expect_error=True)
print()
print('closing the case by erasing the loser:', erased.status, erased.code)
print(' ', erased.message)
assert erased.code == 'POLICY_APPROVAL_REQUIRED'
Télécharger le notebooknotebook.ipynb · IPYNB · 16 cellules
06

Playbook à télécharger

Trois minutes pour mesurer ce qu’une preuve départage vraiment

Le playbook démarre un Corrobore éphémère, exécute le notebook et propose d’ajouter au monde « insider » une prédiction que l’observation d’ASN confirmerait, pour voir la discrimination s’inverser.

  1. 01

    Démarrer un Corrobore jetable en stockage éphémère

  2. 02

    Installer requests

  3. 03

    Exécuter le notebook et lire la table de discrimination

  4. 04

    Ajouter une prédiction concurrente, relancer, comparer

Télécharger le playbookplaybook.md · MARKDOWN · requests
07

Ce qui est disponible

Modèle de mondes, overlays, scoring inter-branches, requêtes contrefactuelles et registre de résolution disponibles dans graph-core. Ce playbook illustre l’API Rust, pas une route HTTP dédiée.