← Toutes les capacités

07 / MULTI-AGENT

Coopération épistémique multi-agent

Plusieurs agents peuvent soutenir, contester, réviser ou rétracter un claim sans écraser la position des autres ni perdre l’auteur de chaque décision.

RUST CORE
01

Pourquoi cette capacité

Partager les désaccords, pas seulement l’état

Claims, liens de soutien ou réfutation, stances d’agents, confiance, politiques de résolution et explications sont des objets typés et interrogeables.

02

Scénario

FIELD NOTE / SCENARIO

Faire coopérer extraction, validation et analyste

L’extracteur propose une attribution, le validateur signale une source faible, un second agent apporte une preuve contraire et l’analyste garde le claim contesté.

  1. 01

    Créer un claim candidat avec ses sources

  2. 02

    Ajouter des stances indépendantes et leur confiance

  3. 03

    Appliquer une politique ou conserver explicitement le désaccord

Résultat attendu

La mémoire commune reste cohérente sans fabriquer de consensus artificiel.

03

Jeu de données synthétique

Trois agents, trois lectures, aucune manifestement fausse

Dix-sept mémoires et seize relations décrivent une question d’attribution que personne ne peut trancher : l’extracteur conclut à Atlas, le validateur relève que le bloc d’adresses a été revendu deux fois et que le meilleur appui est un flux commercial sans base publiée, l’analyste soutient que rien n’est décidable sans le journal d’authentification primaire. Les prises de position sont des enregistrements typés, avec leur provenance et leur confiance, pas une colonne sur le claim.

17Mémoires
16Relations
3Claims concurrents
6Prises de position
3Agents
1Preuves peu fiables
Télécharger le jeu de donnéesdataset.json · JSON · 22 kB
04

Pourquoi Corrobore

Un pipeline qui choisit un gagnant ne résout pas le désaccord : il détruit de l’information.

Ce qu’il faut construire soi-même

  • Un dernier écrivain gagnant fait disparaître la position minoritaire. Six mois plus tard, personne ne peut dire qu’un validateur avait signalé la revente du bloc d’adresses.
  • Stocker un score de consensus agrège des avis mais perd leurs auteurs et leurs fondements : impossible de rejouer qui savait quoi, ni sur quelle preuve.
  • Fusionner des entités dans une base classique est destructif par défaut. Rien n’empêche un agent mal réglé de collapser trois lectures en une seule, et rien n’en garde la trace.

Ce que le contrat garantit

  • Le mode propose ne modifie rien : un agent peut évaluer une fusion sans muter la mémoire partagée, ce qui rend la consolidation spéculative sûre.
  • La fusion destructive est refusée. preserve_disagreements: false renvoie POLICY_APPROVAL_REQUIRED : il n’existe pas de drapeau qui efface une lecture minoritaire.
  • Une approbation est liée à sa proposition : réutiliser un proposal_id valide pour appliquer un autre canonique, ou en retirant discrètement un claim dissident, est refusé.
05

Notebook exécutable

Dix-huit cellules qui refusent de fabriquer un consensus

Le notebook charge le désaccord, le restitue par une seule requête bornée (qui soutient quoi, sur quelle preuve, à quelle fiabilité), propose une fusion et vérifie qu’aucune mémoire n’a bougé, se fait refuser la fusion destructive, se fait refuser deux tentatives de détournement d’approbation, applique enfin la fusion approuvée et constate que les lectures écartées passent en superseded au lieu d’être supprimées.

epistemic/notebook.ipynbEXTRAIT / 19 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
LIMITS = {
    'max_items': 200,
    'max_depth': 4,
    'max_payload_bytes': 1024 * 1024,
    'max_cost': 5000,
    'timeout_ms': 10000,
    'supernode_threshold': 64,
}

state = memory_op('recall', {
    'objective': 'attribution',
    'seed_ids': [ids['case--attribution']],
    'limits': LIMITS,
})

records = {item['record']['id']: item['record'] for item in state['items']}
positions = {}
for edge in state['relationships']:
    if edge['kind'] in ('supports', 'challenges', 'abstains'):
        claim = records[edge['target_id']]['identity_key']
        positions.setdefault(claim, []).append((edge['properties']['agent'], edge['kind'], edge['confidence']))

for claim, held in sorted(positions.items()):
    print(claim)
    for agent, kind, confidence in sorted(held):
        print(f'    {agent:<20} {kind:<12} {confidence:.2f}')

assert len(positions) == 3, 'all three claims survive retrieval'
CODE [3] / PYTHON
claim_ids = [ids[key] for key in ('claim--atlas-owns', 'claim--reseller-owns', 'claim--unresolved')]

# The canonical choice is part of what gets approved, so it belongs in the
# proposal. The analyst's position is that the honest answer is 'unresolved'.
CONSOLIDATION = {
    'memory_ids': claim_ids,
    'canonical_id': ids['claim--unresolved'],
    'reason': 'no primary log, ownership stays open',
    'preserve_disagreements': True,
}

proposal = memory_op('consolidate', dict(CONSOLIDATION, mode={'mode': 'propose'}))
print(json.dumps(proposal, indent=2))

assert proposal['applied'] is False
assert proposal['receipt'] is None

# Nothing moved: every claim is still active at version 1.
for key in ('claim--atlas-owns', 'claim--reseller-owns', 'claim--unresolved'):
    ladder = memory_op('trace', {'target': {'kind': 'memory', 'id': ids[key]}})
    assert [(v['version'], v['lifecycle']) for v in ladder['versions']] == [(1, 'active')]
print('\nno memory was modified by the proposal')
CODE [4] / PYTHON
APPROVED = {
    'mode': 'apply_approved',
    'proposal_id': proposal['proposal_id'],
    'approval_policy': 'policy--analyst-review',
}

refused = memory_op('consolidate', dict(CONSOLIDATION,
                                        mode=APPROVED,
                                        preserve_disagreements=False,
                                        reason='collapse to the most confident claim'),
                    idempotency_key='epistemic:destructive', expect_error=True)

print(refused.status, refused.code)
print(refused.message)
assert refused.code == 'POLICY_APPROVAL_REQUIRED'
CODE [5] / PYTHON
swapped = memory_op('consolidate', dict(CONSOLIDATION,
                                        mode=APPROVED,
                                        canonical_id=ids['claim--atlas-owns']),
                    idempotency_key='epistemic:swapped', expect_error=True)

print(swapped.status, swapped.code, '-', swapped.message)
assert swapped.code == 'POLICY_APPROVAL_REQUIRED'

# Quietly dropping a dissenting claim from the candidate set is refused too.
narrowed = memory_op('consolidate', dict(CONSOLIDATION,
                                         mode=APPROVED,
                                         memory_ids=claim_ids[1:]),
                     idempotency_key='epistemic:narrowed', expect_error=True)
print(narrowed.status, narrowed.code, '-', narrowed.message)
assert narrowed.code == 'POLICY_APPROVAL_REQUIRED'

# Input validation runs first: a canonical outside the candidate set never
# reaches the approval check at all.
orphaned = memory_op('consolidate', dict(CONSOLIDATION,
                                         mode=APPROVED,
                                         memory_ids=claim_ids[:2]),
                     idempotency_key='epistemic:orphaned', expect_error=True)
print(orphaned.status, orphaned.code, '-', orphaned.message)
assert orphaned.code == 'INVALID_REQUEST'
CODE [6] / PYTHON
applied = memory_op('consolidate', dict(CONSOLIDATION, mode=APPROVED),
                    idempotency_key='epistemic:apply')

print('applied      :', applied['applied'])
print('canonical    :', by_id[applied['canonical_id']])
print('retained     :', [by_id[i] for i in applied['originals_retained']])
print('disagreements:', applied['disagreements_retained'])
print('receipt      :', applied['receipt'])

assert applied['disagreements_retained'] is True
assert len(applied['originals_retained']) == 3
CODE [7] / PYTHON
after = memory_op('recall', {
    'objective': 'attribution',
    'seed_ids': [ids['case--attribution']],
    'limits': LIMITS,
})
returned = {item['record']['identity_key'] for item in after['items']}

print('claims still retrieved:', sorted(k for k in returned if k.startswith('claim--')))
print()
for key in ('claim--atlas-owns', 'claim--reseller-owns', 'claim--unresolved'):
    ladder = memory_op('trace', {'target': {'kind': 'memory', 'id': ids[key]}})
    print(f"{key:<24} {[(v['version'], v['lifecycle']) for v in ladder['versions']]}")

# The superseded readings are gone from retrieval and intact in the audit.
assert 'claim--unresolved' in returned
assert 'claim--atlas-owns' not in returned
Télécharger le notebooknotebook.ipynb · IPYNB · 19 cellules
06

Playbook à télécharger

Trois minutes pour vérifier qu’un désaccord survit

Le playbook démarre un Corrobore éphémère, exécute le notebook et propose de changer le claim canonique ou de retirer la permission consolidate pour voir la frontière d’autorisation se déplacer.

  1. 01

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

  2. 02

    Installer requests

  3. 03

    Exécuter le notebook et lire les refus obtenus

  4. 04

    Changer le claim canonique, relancer, comparer

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

Ce qui est disponible

ClaimStore, stances, liens épistémiques, espaces d’hypothèses et politiques de résolution disponibles dans graph-core. La page Agentic montre la composition de workflows au-dessus de ces primitives.