← Toutes les capacités

09 / REPRESENTATION

Multi-résolution et hyperrelations

La même enquête peut être lue comme événements bruts, épisodes, campagnes ou coordination, sans perdre les liens de dérivation ni réduire un événement collectif à des paires artificielles.

RUST CORE
01

Pourquoi cette capacité

Choisir le niveau adapté à la question

Les niveaux de résolution restent liés à leurs sources. Les hyperrelations représentent un événement n-aire avec participants, rôles et fenêtre temporelle. Les traversées peuvent mélanger les deux modèles.

02

Scénario

FIELD NOTE / SCENARIO

Passer de 128 publications à une campagne coordonnée

Un analyste cherche d’abord les posts bruts, puis demande une vue campagne. L’événement coordonné conserve les comptes, rôles, messages et fenêtre temporelle dans un seul objet.

  1. 01

    Dériver des résumés sans perdre les événements sources

  2. 02

    Modéliser la coordination comme hyperrelation

  3. 03

    Sélectionner automatiquement la résolution utile à la question

Résultat attendu

L’agent répond au bon niveau de détail et peut toujours redescendre jusqu’à la preuve brute.

03

Jeu de données synthétique

Une campagne, trois résolutions, et le chemin de retour

La même campagne d’influence coordonnée existe trois fois dans le graphe : 128 publications brutes, six résumés d’épisodes qui en dérivent, et un objet campagne. Rien n’est dupliqué à la légère, parce que chaque résumé conserve ses arêtes derived_from vers les publications dont il provient. S’y ajoute un événement de coordination n-aire : huit comptes publiant dans la même fenêtre de quatre minutes, chacun dans un rôle distinct.

150Mémoires
263Relations
128Publications brutes
6Épisodes
14Comptes
8Participants coordonnés
Télécharger le jeu de donnéesdataset.json · JSON · 228 kB
04

Pourquoi Corrobore

Répondre au mauvais niveau n’est pas de la rigueur, c’est un échec.

Ce qu’il faut construire soi-même

  • Résumer détruit généralement la source. Le résumé devient infalsifiable : personne ne peut vérifier de quelles publications il sort, ni redescendre quand la question l’exige.
  • Un modèle d’arêtes binaires ne sait pas représenter un événement collectif. Huit comptes coordonnés deviennent 28 arêtes deux à deux qui affirment ce que personne n’a observé, et les rôles disparaissent.
  • Sans niveaux explicites, chaque question part du grain le plus fin. On paie 128 publications pour répondre à une question à laquelle l’objet campagne répond seul.

Ce que le contrat garantit

  • Le résumé est une vue, pas un remplacement : dans le notebook, les six épisodes rouvrent sur exactement 128 publications, sans recouvrement ni perte.
  • Chaque niveau se récupère sous budget explicite, donc le coût du choix de résolution est mesurable avant de le payer : ici un rapport de 19,5 sur la charge utile.
  • L’arité de ce qui a été observé survit. L’événement de coordination reste un enregistrement portant ses participants, leurs rôles et sa fenêtre temporelle.
05

Notebook exécutable

Onze cellules qui mesurent le prix du mauvais niveau

Le notebook pose la même question depuis les publications brutes puis depuis l’objet campagne, et mesure l’écart : 136 enregistrements contre 7, soit 19,5 fois plus de charge utile pour une réponse qui n’est pas meilleure. Il rouvre ensuite chaque épisode vers ses publications sources et vérifie que les six partitionnent exactement le corpus, puis compare l’événement n-aire à son explosion en 28 arêtes deux à deux, et termine sur une table de routage question vers niveau.

multi-resolution/notebook.ipynbEXTRAIT / 13 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
BUDGET = {
    'max_items': 500, 'max_depth': 3, 'max_payload_bytes': 4 * 1024 * 1024,
    'max_cost': 20000, 'timeout_ms': 20000, 'supernode_threshold': 512,
}

EPISODE_KEYS = ['episode--seed', 'episode--amplify-1', 'episode--localise',
                'episode--authority', 'episode--amplify-2', 'episode--decay']


def ask(objective, seeds, limits=BUDGET):
    if isinstance(seeds, str):
        seeds = [seeds]
    return memory_op('recall', {
        'objective': objective,
        'seed_ids': [ids[s] for s in seeds],
        'limits': limits,
    })


raw = ask('tidewater', EPISODE_KEYS, dict(BUDGET, max_depth=1))
coarse = ask('tidewater', 'campaign--tidewater', dict(BUDGET, max_depth=1))

for label, result in (('raw posts', raw), ('campaign view', coarse)):
    kinds = {}
    for item in result['items']:
        kinds[item['record']['kind']] = kinds.get(item['record']['kind'], 0) + 1
    print(f"{label:<16} items {len(result['items']):>4}  cost {result['usage']['cost']:>5}"
          f"  bytes {result['usage']['payload_bytes']:>7}  {dict(sorted(kinds.items()))}")

print()
print(f"payload ratio: {raw['usage']['payload_bytes'] / coarse['usage']['payload_bytes']:.1f}x")

assert len(coarse['items']) < len(raw['items'])
assert coarse['usage']['payload_bytes'] < raw['usage']['payload_bytes']
CODE [3] / PYTHON
def open_episode(key):
    """Return the posts a single episode was derived from."""
    opened = ask('derivation', key, dict(BUDGET, max_depth=1))
    records = {i['record']['id']: i['record'] for i in opened['items']}
    origin = ids[key]
    posts = [records[e['target_id']]['identity_key']
             for e in opened['relationships']
             if e['kind'] == 'derived_from' and e['source_id'] == origin
             and e['target_id'] in records]
    return posts, opened['usage']


posts, usage = open_episode('episode--amplify-1')
print('episode        : episode--amplify-1')
print('posts behind it:', len(posts))
print('sample         :', sorted(posts)[:6])
print('cost to open   :', usage['cost'])

# Every episode opens onto its own slice, and the slices partition the corpus.
print()
total = 0
for key in EPISODE_KEYS:
    backing, _ = open_episode(key)
    total += len(backing)
    print(f'{key:<22} opens onto {len(backing):>3} source posts')
    assert backing, f'{key} has no path back to its evidence'

print(f'\ntotal reachable posts: {total} of {dataset["facts"]["raw_posts"]}')
assert total == dataset['facts']['raw_posts']
CODE [4] / PYTHON
burst = next(r['record'] for r in ask('coordination', 'event--four-minute-burst',
                                      dict(BUDGET, max_depth=1))['items']
             if r['record']['identity_key'] == 'event--four-minute-burst')
props = burst['content']['value']['properties']
participants = props['participants']

print('window     :', props['window_start'], '->', props['window_end'])
print('participants:', props['participant_count'])
for account, role in sorted(participants.items()):
    print(f'    {account}  {role}')

n = props['participant_count']
pairwise = n * (n - 1) // 2
print()
print(f'as one n-ary event : 1 record, {n} roles')
print(f'as pairwise edges  : {pairwise} edges, and no role survives')
assert pairwise == 28
Télécharger le notebooknotebook.ipynb · IPYNB · 13 cellules
06

Playbook à télécharger

Quatre minutes, 420 enregistrements, trois façons de lire la même chose

Le playbook démarre un Corrobore éphémère, exécute le notebook et propose de supprimer les arêtes de dérivation pour constater que le résumé devient invérifiable.

  1. 01

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

  2. 02

    Installer requests

  3. 03

    Exécuter le notebook et comparer les charges utiles

  4. 04

    Retirer les arêtes de dérivation, relancer, constater la perte

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

Ce qui est disponible

Modèle de résolution, sélection orientée question, hyperrelations et traversées mixtes disponibles dans graph-core. Les projections visuelles restent à la charge de l’interface.