← Toutes les capacités

06 / ACTIVE INVESTIGATION

Next Best Evidence

Quand l’incertitude demeure, Corrobore classe la prochaine preuve à chercher selon le gain attendu, le coût, le temps, le risque et la politique.

RUST CORE
01

Pourquoi cette capacité

Décider quoi chercher, ou quand s’arrêter

Chaque candidat reçoit un score décomposé et des contraintes d’éligibilité. La décision d’arrêt tient compte de la confiance, de la complétude, des contradictions et du budget restant.

02

Scénario

FIELD NOTE / SCENARIO

Choisir entre DNS historique et image disque

Le DNS historique est moins complet mais rapide et peu risqué. L’image disque promet plus d’information mais dépasse le délai. Le classement garde les raisons visibles.

  1. 01

    Décrire gain, coût, risque et portée de chaque action

  2. 02

    Écarter les candidats hors budget ou politique

  3. 03

    Sélectionner l’action ou produire une abstention calibrée

Résultat attendu

L’agent dépense son prochain euro et sa prochaine minute sur la preuve qui réduit le mieux l’incertitude admissible.

03

Jeu de données synthétique

Sept actions de collecte, quatre lacunes, 48 heures

Le jeu de données est construit pour que l’heuristique évidente échoue. L’action au gain le plus élevé, une réquisition auprès de l’hébergeur, demande 240 heures et rate l’échéance. Une autre exige une autorité juridique que personne n’a encore. Et deux actions paraissent productives sans fermer la moindre lacune, dont l’achat d’un rapport d’enrichissement auprès du flux qui a déjà produit la preuve la plus faible du dossier.

12Mémoires
16Relations
7Actions candidates
4Lacunes ouvertes
48Échéance (heures)
2Actions qui ne ferment rien
Télécharger le jeu de donnéesdataset.json · JSON · 18 kB
04

Pourquoi Corrobore

Classer par gain attendu recommande poliment quelque chose que vous ne pouvez pas faire.

Ce qu’il faut construire soi-même

  • Un score unique mélange le gain et la faisabilité. « Nécessite une réquisition » devient une petite pénalité au lieu d’une exclusion, et le classement finit par proposer une action impossible.
  • Rien ne distingue structurellement une action utile d’une action occupée. Acheter un rapport au même flux commercial se classe comme n’importe quoi d’autre, faute de lien vers une lacune ouverte.
  • La décision d’arrêt devient un délai qui expire. Personne ne sait quelles lacunes sont restées ouvertes, ni quelle contrainte les a bloquées.

Ce que le contrat garantit

  • Lacunes et actions sont des enregistrements liés par une relation would_close : une action qui ne ferme rien se détecte par l’absence d’arête, pas par un mauvais score.
  • Fermer une lacune passe par un update audité avec expected_version, donc la boucle relit un état réel au lieu d’un cache local.
  • L’arrêt produit une sortie : la liste nommée des lacunes restées ouvertes et, pour chacune, la contrainte qui l’a bloquée.
05

Notebook exécutable

Onze cellules qui refusent l’action la plus prometteuse

Le notebook construit un score décomposé, traite l’éligibilité comme un filtre et non comme une pénalité, et vérifie que l’action au gain maximal est écartée pour dépassement d’échéance. Il exécute le meilleur coup admissible, ferme sa lacune dans le graphe, relit l’état, reclasse, puis boucle jusqu’à ce qu’aucune action éligible ne franchisse le seuil. La sortie finale est une abstention calibrée : une lacune à fort impact reste ouverte, échéance et budget à l’appui.

next-best-evidence/notebook.ipynbEXTRAIT / 12 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
IMPACT_WEIGHT = {'high': 1.0, 'medium': 0.6, 'low': 0.3}
DEADLINE_HOURS = 48
BUDGET = 1.0


def assess(actions, gaps, closes, budget=BUDGET, deadline=DEADLINE_HOURS):
    rows = []
    for key, record in actions.items():
        props = record['content']['value']['properties']
        target = closes.get(key)
        open_target = target in gaps and gaps[target]['content']['value']['properties']['status'] == 'open'
        impact = gaps[target]['content']['value']['properties']['impact'] if open_target else None

        blockers = []
        if props['hours'] > deadline:
            blockers.append(f"exceeds deadline ({props['hours']}h > {deadline}h)")
        if props['authority'] != 'granted':
            blockers.append(f"authority {props['authority']}")
        if props['cost'] > budget:
            blockers.append('over budget')
        if not open_target:
            blockers.append('closes no open gap')

        weight = IMPACT_WEIGHT.get(impact, 0.0)
        score = (props['expected_gain'] * weight) / (props['cost'] + 0.1) * (1 - props['risk'])
        rows.append({
            'action': key, 'gain': props['expected_gain'], 'cost': props['cost'],
            'hours': props['hours'], 'risk': props['risk'], 'closes': target,
            'impact': impact, 'score': round(score, 3),
            'eligible': not blockers, 'blockers': blockers,
        })
    return sorted(rows, key=lambda r: (-r['eligible'], -r['score']))


ranking = assess(actions, gaps, closes)
print(f"{'action':<28}{'gain':>6}{'cost':>6}{'hours':>7}{'score':>8}  eligibility")
print('-' * 84)
for row in ranking:
    verdict = 'eligible' if row['eligible'] else '; '.join(row['blockers'])
    print(f"{row['action']:<28}{row['gain']:>6}{row['cost']:>6}{row['hours']:>7}"
          f"{row['score']:>8}  {verdict}")
CODE [3] / PYTHON
top = next(r for r in ranking if r['eligible'])
highest_gain = max(ranking, key=lambda r: r['gain'])

print('highest expected gain :', highest_gain['action'], highest_gain['gain'])
print('    eligible?         :', highest_gain['eligible'], highest_gain['blockers'])
print('chosen action         :', top['action'], 'score', top['score'])

# Ranking by gain alone would have picked an action that misses the deadline.
assert highest_gain['eligible'] is False
assert top['action'] != highest_gain['action']

# The two busywork actions are excluded for closing nothing, not for scoring badly.
busywork = [r['action'] for r in ranking if 'closes no open gap' in r['blockers']]
print('excluded as busywork  :', busywork)
assert len(busywork) == dataset['facts']['actions_closing_nothing']
CODE [4] / PYTHON
def close_gap(gap_key, note):
    ladder = memory_op('trace', {'target': {'kind': 'memory', 'id': ids[gap_key]}})
    return memory_op('update', {
        'target': {'kind': 'memory', 'id': ids[gap_key]},
        'expected_version': ladder['versions'][-1]['version'],
        'patch': {
            'content': {'format': 'text_and_properties',
                        'value': {'text': note, 'properties': {'impact': 'high', 'status': 'closed'}}},
            'confidence': 0.95,
            'add_provenance': [{'source_id': 'collection', 'locator': gap_key, 'observed_at': None}],
            'add_tags': ['closed'],
        },
    }, idempotency_key=f'nbe:close:{gap_key}')


closed = close_gap(top['closes'], f"Gap closed by {top['action']}.")
print('closed  :', top['closes'], '-> version', closed['record']['version'])
print('status  :', closed['record']['content']['value']['properties']['status'])

actions, gaps, closes = read_state()
ranking = assess(actions, gaps, closes, budget=BUDGET - top['cost'])

print()
print('re-ranked after acting:')
for row in ranking[:4]:
    verdict = 'eligible' if row['eligible'] else '; '.join(row['blockers'])
    print(f"  {row['action']:<28}{row['score']:>8}  {verdict}")

# The action we just performed is no longer a candidate: its gap is closed.
performed = next(r for r in ranking if r['action'] == top['action'])
assert performed['eligible'] is False
assert 'closes no open gap' in performed['blockers']
CODE [5] / PYTHON
MIN_SCORE = 0.35
budget, spent, performed_actions = BUDGET, 0.0, []

while True:
    actions, gaps, closes = read_state()
    ranking = assess(actions, gaps, closes, budget=budget - spent)
    candidate = next((r for r in ranking if r['eligible'] and r['score'] >= MIN_SCORE), None)
    if candidate is None:
        break
    close_gap(candidate['closes'], f"Gap closed by {candidate['action']}.")
    spent += candidate['cost']
    performed_actions.append(candidate['action'])
    print(f"performed {candidate['action']:<28} score {candidate['score']:<7} spent {spent:.2f}")

actions, gaps, closes = read_state()
still_open = {k: v['content']['value']['properties']
              for k, v in gaps.items()
              if v['content']['value']['properties']['status'] == 'open'}

print()
print('stopped after', len(performed_actions), 'actions, budget spent', round(spent, 2))
print()
print('calibrated abstention:')
for gap, props in sorted(still_open.items()):
    reasons = [r['blockers'] for r in assess(actions, gaps, closes, budget=budget - spent)
               if closes.get(r['action']) == gap]
    flat = sorted({b for group in reasons for b in group})
    print(f"  {gap} ({props['impact']} impact) remains open because: {', '.join(flat) or 'no action targets it'}")

assert still_open, 'this investigation cannot be fully closed within its constraints'
Télécharger le notebooknotebook.ipynb · IPYNB · 12 cellules
06

Playbook à télécharger

Trois minutes pour voir une boucle d’enquête s’arrêter proprement

Le playbook démarre un Corrobore éphémère, exécute le notebook et propose de repousser l’échéance à 300 heures pour voir la réquisition devenir le bon coup.

  1. 01

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

  2. 02

    Installer requests

  3. 03

    Exécuter le notebook et lire le classement décomposé

  4. 04

    Repousser l’échéance, relancer, comparer la décision

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

Ce qui est disponible

Classement Next Best Evidence, gain d’information, probes de vérification et décisions d’arrêt disponibles dans graph-core. L’exécution des collectes externes reste à l’intégrateur.