UmweltDocs
Recipes/A rumor that spreads

A rumor that spreads

A villager sees something at midnight. By the fifth day, half the village believes a worse story, and the truth reaches only those who were there.

VillagerDay 1Day 2Day 3Day 4Day 5
Tamthe miller’s apprentice
took1.00
took1.00
took1.00
took1.00
took1.00
Bessthe baker
·
stole0.90
stole0.90
stole0.90
dropped
Wilthe smith, an elder
·
took0.60
took0.60
took0.60
took0.60
Idathe innkeeper
·
·
sold0.81
sold0.81
sold0.81
Rookthe ferryman
·
·
·
sold0.77
sold0.77
Marenthe herbalist, an elder
·
·
stole0.45
stole0.45
dropped
Oswinthe reeve
·
·
·
·
·
took the grain (true)stole itsold it to the river tradersdropped, after seeing the loft

Every cell is an answer from the engine: what whoHolds returns for each version of the story, on each day, from the program below.

The idea

Gossip has two halves. Who talks to whom, how much they trust each other and how a story bends in the telling are rules of your game. Who heard what, from whom and since when is a record. Keep the rules in your code and the record in Umwelt, and every question about the rumor has an exact answer.

Your game decides

  • who meets whom each day
  • how likely a story is to grow in the telling
  • how confidence falls with trust
  • that nobody gossips to the reeve’s face
  • who drops the story when they see the loft

Umwelt records

  • each telling, as a Holding told-by the teller
  • the bent version, with distortedClaim
  • trust, as Dispositions your rule reads back
  • the chain back to the one who saw it
  • every belief canon contradicts

The village

Thornmere, the night before the rain. Oswin, the reeve, carries the seed grain from the granary up to the dry loft so it will not rot.

Tam, the miller’s apprentice, sees him carry the sacks out at midnight. That is all Tam sees.

Tamthe miller’s apprenticetam
Bessthe bakerbess
Wilthe smith, an elderwil
Idathe innkeeperida
Rookthe ferrymanrook
Marenthe herbalist, an eldermaren
Oswinthe reeveoswin
The eldersa group: Wil and Marenelders

Canon: what really happened

Canon says the reeve took the grain, that it is kept in the loft, and, just as plainly, that he neither stole it nor sold it. Tam gets one Holding: what he saw. The elders are a group, and membership is a Fact.

thornmere.ts
// What happened: the reeve moved the seed grain to the dry loft before the rain.
const took = claim.entity('oswin', 'took', 'seed-grain');
const stole = claim.entity('oswin', 'stole', 'seed-grain');
const sold = claim.entity('oswin', 'sold', 'seed-grain', { qualifiers: { to: 'the river traders' } });
const inLoft = claim.entity('seed-grain', 'kept-in', 'loft');
const ladder = [took, stole, sold];

await client.commit({
  projectId, attemptId: 'night-1',
  event: { id: 'night-1', type: 'night', title: 'Sacks in the dark', position: at(1), participants: ['oswin', 'tam'], provenance },
  records: [
    fact(took, { id: 'canon-took' }),
    fact(inLoft, { id: 'canon-loft' }),
    fact(negate(stole), { id: 'canon-not-stolen' }),
    fact(negate(sold), { id: 'canon-not-sold' }),
    knows('oswin', inLoft, witnessed('night-1'), { id: 'oswin-loft' }),
    knows('tam', took, witnessed('night-1'), { id: 'tam-saw', text: 'I saw the reeve carry the seed grain out of the granary at midnight.' }),
    fact(claim.entity('maren', 'umwelt:member-of', 'elders'), { id: 'maren-elder' }),
    fact(claim.entity('wil', 'umwelt:member-of', 'elders'), { id: 'wil-elder' }),
  ],
});
Program.cs
// What happened: the reeve moved the seed grain to the dry loft before the rain.
var took = Claim.Entity("oswin", "took", "seed-grain");
var stole = Claim.Entity("oswin", "stole", "seed-grain");
var sold = Claim.Entity("oswin", "sold", "seed-grain", qualifiers: new Dictionary<string, object?> { ["to"] = "the river traders" });
var inLoft = Claim.Entity("seed-grain", "kept-in", "loft");
var ladder = new[] { took, stole, sold };
var night = Position.At(1);
var witnessed = Acquired.Witnessed("night-1");

engine.Commit(new CommitInput
{
    ProjectId = projectId,
    AttemptId = "night-1",
    Event = new EventInput { Id = "night-1", Type = "night", Title = "Sacks in the dark", Position = night, Participants = new() { "oswin", "tam" }, Provenance = provenance },
    Records = new()
    {
        Records.Fact(took, id: "canon-took"),
        Records.Fact(inLoft, id: "canon-loft"),
        Records.Fact(stole.Negated(), id: "canon-not-stolen"),
        Records.Fact(sold.Negated(), id: "canon-not-sold"),
        Records.Knows("oswin", inLoft, witnessed, id: "oswin-loft"),
        Records.Knows("tam", took, witnessed, id: "tam-saw", text: "I saw the reeve carry the seed grain out of the granary at midnight."),
        Records.Fact(Claim.Entity("maren", "umwelt:member-of", "elders"), id: "maren-elder"),
        Records.Fact(Claim.Entity("wil", "umwelt:member-of", "elders"), id: "wil-elder"),
    },
});

A Fact is nobody’s knowledge. The canon records make no villager know anything. Only Tam’s Holding does.

Trust

How much each villager believes the others. The numbers are your game’s; stored as Dispositions, your rule can read them back on any day, and you can ask later why someone believed what they did.

thornmere.ts
// How much each villager trusts the others. Your game owns these numbers; Umwelt keeps them.
const trust: [string, string, number][] = [
  ['bess', 'tam', 0.9], ['wil', 'tam', 0.6], ['ida', 'bess', 0.9], ['maren', 'bess', 0.5],
  ['ida', 'wil', 0.8], ['rook', 'ida', 0.95], ['maren', 'ida', 0.7], ['maren', 'rook', 0.4],
  ['oswin', 'wil', 0.9], ['oswin', 'maren', 0.9], ['bess', 'ida', 0.8], ['wil', 'ida', 0.7],
];
await client.dispositionSetBatch({
  projectId, attemptId: 'trust', position: at(1),
  dispositions: trust.map(([holder, target, value]) => ({ holder, target, dimension: 'trust', value })),
});
Program.cs
// How much each villager trusts the others. Your game owns these numbers; Umwelt keeps them.
var trust = new (string Holder, string Target, double Value)[]
{
    ("bess", "tam", 0.9), ("wil", "tam", 0.6), ("ida", "bess", 0.9), ("maren", "bess", 0.5),
    ("ida", "wil", 0.8), ("rook", "ida", 0.95), ("maren", "ida", 0.7), ("maren", "rook", 0.4),
    ("oswin", "wil", 0.9), ("oswin", "maren", 0.9), ("bess", "ida", 0.8), ("wil", "ida", 0.7),
};
engine.DispositionSetBatch(new SetDispositionsInput
{
    ProjectId = projectId,
    AttemptId = "trust",
    Position = night,
    Dispositions = trust.Select(t => new DispositionSetting
    {
        Holder = t.Holder, Target = t.Target, Dimension = "trust", Value = JsonSerializer.SerializeToElement(t.Value),
    }).ToList(),
});

The gossip rule

Each day, everyone sure enough of the story tells the people they happen to meet. With some luck the story gets worse: took becomes stole, stole becomes sold. Confidence is the teller’s, times the listener’s trust. All of it is ordinary code, with a seeded random so the village gossips the same way every run.

thornmere.tsYour code
// The gossip rule lives in your code. Umwelt never decides who talks or what they believe.
const meets: Record<string, string[]> = {
  tam: ['bess', 'wil'], bess: ['tam', 'ida', 'maren'], wil: ['tam', 'ida', 'oswin'],
  ida: ['bess', 'wil', 'rook', 'maren'], rook: ['ida', 'maren'], maren: ['bess', 'ida', 'rook', 'oswin'],
  oswin: ['wil', 'maren'],
};
let seed = 12;
const random = () => {
  seed = (seed + 0x6d2b79f5) | 0;
  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};

// Who holds which version of the story, read back from Umwelt.
async function versionsAt(tick: number) {
  const held = new Map<string, { rung: number; recordId: string; confidence: number }>();
  for (const [rung, claim] of ladder.entries()) {
    for (const item of await client.whoHolds({ projectId, claim, at: { tick }, filter: { polarity: true } })) {
      held.set(item.holder, { rung, recordId: item.recordId, confidence: item.confidence });
    }
  }
  return held;
}

async function trustOf(listener: string, teller: string, tick: number) {
  const [trust] = await client.dispositions({ projectId, holder: listener, target: teller, dimension: 'trust', at: { tick } });
  return trust?.kind === 'disposition' ? Number(trust.value) : 0.5;
}

for (const day of [2, 3, 4]) {
  const held = await versionsAt(day - 1);
  const told = new Set<string>();
  const transmits: TransmitBatchItem[] = [];
  for (const [teller, story] of [...held].sort(([a], [b]) => a.localeCompare(b))) {
    if (story.confidence < 0.5) continue;                     // too unsure to repeat it
    for (const listener of meets[teller]) {
      if (listener === 'oswin' || held.has(listener) || told.has(listener)) continue;
      if (random() > 0.7) continue;                           // they did not meet today
      const rung = Math.min(2, story.rung + (random() < 0.4 ? 1 : 0));
      const confidence = Math.round(story.confidence * (await trustOf(listener, teller, day)) * 100) / 100;
      told.add(listener);
      transmits.push({
        attemptId: `day-${day}-${teller}-${listener}`,
        from: teller, to: listener, sourceRecordId: story.recordId,
        distortedClaim: rung !== story.rung ? ladder[rung] : null,
        attitude: confidence >= 0.6 ? 'believes' : 'suspects',
        confidence, recordId: `${listener}-heard`,
      });
    }
  }
  if (!transmits.length) continue;                            // a quiet day
  // What every telling of the day has in common is said once. They believe what they say.
  const outcomes = await client.transmitBatch({ shared: { projectId, intent: 'honest', position: at(day) }, transmits });
  if (day === 2) print('day-2', outcomes);
}
Program.csYour code
// The gossip rule lives in your code. Umwelt never decides who talks or what they believe.
var meets = new Dictionary<string, string[]>
{
    ["tam"] = new[] { "bess", "wil" }, ["bess"] = new[] { "tam", "ida", "maren" }, ["wil"] = new[] { "tam", "ida", "oswin" },
    ["ida"] = new[] { "bess", "wil", "rook", "maren" }, ["rook"] = new[] { "ida", "maren" },
    ["maren"] = new[] { "bess", "ida", "rook", "oswin" }, ["oswin"] = new[] { "wil", "maren" },
};
var seed = 12;
double Random()
{
    unchecked
    {
        seed += 0x6d2b79f5;
        var t = (seed ^ (int)((uint)seed >> 15)) * (1 | seed);
        t = (t + (t ^ (int)((uint)t >> 7)) * (61 | t)) ^ t;
        return (uint)(t ^ (int)((uint)t >> 14)) / 4294967296.0;
    }
}

// Who holds which version of the story, read back from Umwelt.
Dictionary<string, (int Rung, string RecordId, double Confidence)> VersionsAt(long tick)
{
    var held = new Dictionary<string, (int, string, double)>();
    for (var rung = 0; rung < ladder.Length; rung++)
    {
        var filter = new WhoHoldsFilter { Polarity = true };
        foreach (var item in engine.WhoHolds(new WhoHoldsPayload { ProjectId = projectId, Claim = ladder[rung], At = new At { Tick = tick }, Filter = filter }))
        {
            held[item.Holder] = (rung, item.RecordId, item.Confidence);
        }
    }
    return held;
}

double TrustOf(string listener, string teller, long tick)
{
    var trust = engine.Dispositions(new DispositionsPayload { ProjectId = projectId, Holder = listener, Target = teller, Dimension = "trust", At = new At { Tick = tick } });
    return trust.OfType<Record.Disposition>().FirstOrDefault()?.Value.GetDouble() ?? 0.5;
}

foreach (var day in new long[] { 2, 3, 4 })
{
    var held = VersionsAt(day - 1);
    var told = new HashSet<string>();
    var transmits = new List<TransmitBatchItem>();
    foreach (var (teller, story) in held.OrderBy(pair => pair.Key, StringComparer.Ordinal))
    {
        if (story.Confidence < 0.5) continue;                    // too unsure to repeat it
        foreach (var listener in meets[teller])
        {
            if (listener == "oswin" || held.ContainsKey(listener) || told.Contains(listener)) continue;
            if (Random() > 0.7) continue;                        // they did not meet today
            var rung = Math.Min(2, story.Rung + (Random() < 0.4 ? 1 : 0));
            var confidence = Math.Round(story.Confidence * TrustOf(listener, teller, day) * 100, MidpointRounding.AwayFromZero) / 100;
            told.Add(listener);
            transmits.Add(new TransmitBatchItem
            {
                AttemptId = $"day-{day}-{teller}-{listener}",
                From = teller, To = listener, SourceRecordId = story.RecordId,
                DistortedClaim = rung != story.Rung ? ladder[rung] : null,
                Attitude = confidence >= 0.6 ? Attitude.Believes : Attitude.Suspects,
                Confidence = confidence, RecordId = $"{listener}-heard",
            });
        }
    }
    if (transmits.Count == 0) continue;                          // a quiet day
    // What every telling of the day has in common is said once. They believe what they say.
    var outcomes = engine.TransmitBatch(new TransmitBatchPayload
    {
        Shared = new TransmitShared { ProjectId = projectId, Intent = Intent.Honest, Position = Position.At(day) },
        Transmits = transmits,
    });
    if (day == 2) Print("day-2", outcomes);
}

The first day of gossip sends two tellings in one batch. Tam tells Bess, and the story bends; Tam tells Wil, and it does not.

Response
[
  {
    "eventId": "01a1cbb7-cc12-7003-b39a-925053460fd2",
    "recordIds": ["bess-heard"],
    "transitionIds": [],
    "version": 3,
    "warnings": []
  },
  {
    "eventId": "01a1cbb7-cc12-7003-b39a-92699797d4d3",
    "recordIds": ["wil-heard"],
    "transitionIds": [],
    "version": 4,
    "warnings": []
  }
]

Intent is honest. Nobody in Thornmere lies: they repeat what they believe. A distorted claim with intent: "honest" is a story that bent on its own. A liar would be deceptive.

The truth comes out

On day five Oswin opens the loft. Wil, Maren and Bess are there and see the grain. Your rule says that whoever sees it drops the theft story, so their Holdings move to abandoned. Wil only ever held that Oswin took the grain, which is true, so he keeps it. Nobody else changes their mind.

thornmere.ts
// Day 5: the reeve opens the loft. Whoever sees the grain drops the theft story; nobody else does.
const present = ['wil', 'maren', 'bess'];
await client.witness({
  projectId, attemptId: 'day-5',
  event: { id: 'loft-opened', type: 'scene', title: 'The loft is opened', position: at(5), participants: ['oswin', ...present], provenance },
  claims: [{ claim: inLoft }],
  witnesses: present.map((holder) => ({ holder })),
});
const before = await versionsAt(5);
const disproved = present.filter((who) => (before.get(who)?.rung ?? 0) > 0).map((who) => before.get(who)!.recordId);
if (disproved.length) {
  await client.transitionBatch({ projectId, attemptId: 'abandon', position: at(5), recordIds: disproved, status: 'abandoned' });
}
Program.cs
// Day 5: the reeve opens the loft. Whoever sees the grain drops the theft story; nobody else does.
var present = new List<string> { "wil", "maren", "bess" };
engine.Witness(new WitnessInput
{
    ProjectId = projectId,
    AttemptId = "day-5",
    Event = new EventInput
    {
        Id = "loft-opened", Type = "scene", Title = "The loft is opened", Position = Position.At(5),
        Participants = present.Prepend("oswin").ToList(), Provenance = provenance,
    },
    Claims = new() { new WitnessedClaim { Claim = inLoft } },
    Witnesses = present.Select(holder => new Witness { Holder = holder }).ToList(),
});
var before = VersionsAt(5);
var disproved = present.Where(who => before.TryGetValue(who, out var story) && story.Rung > 0).Select(who => before[who].RecordId).ToList();
if (disproved.Count > 0)
{
    engine.TransitionBatch(new TransitionBatchInput
    {
        ProjectId = projectId, AttemptId = "abandon", Position = Position.At(5), RecordIds = disproved, Status = TransitionStatus.Abandoned,
    });
}

Nothing is deleted. Read day four and Bess still believes Oswin stole the grain. The transition only ends the belief from day five on.

Ask the village

thornmere.ts
const spread: Record<string, (string | number)[][]> = {};
for (const day of [1, 2, 3, 4, 5]) {
  const held = await versionsAt(day);
  spread[day] = Object.keys(villagers).map((who) => {
    const story = held.get(who);
    return story ? [who, ladder[story.rung].predicate, story.confidence] : [who, '', 0];
  });
}
print('spread', spread);

print('whoHolds-stole', await client.whoHolds({ projectId, claim: stole, at: { tick: 4 } }));

const farthest = [...(await versionsAt(4))].find(([, story]) => story.rung === 2);
if (farthest) {
  const trace = await client.trace({ projectId, recordId: farthest[1].recordId });
  print('trace', trace.chain.map(({ record, acquisition }) => ({
    holder: record.kind === 'holding' ? record.holder : null,
    says: record.kind === 'holding' ? record.claim.predicate : null,
    confidence: record.kind === 'holding' ? record.confidence : null,
    how: acquisition?.kind,
    from: acquisition?.kind === 'told-by' ? acquisition.sourceHolderId : null,
  })));
  print('contradictions', await client.contradictions({ projectId, holder: farthest[0], at: { tick: 5 } }));
}

print('council-day-4', await client.groupCoverage({ projectId, group: 'elders', claim: stole, at: { tick: 4 } }));
print('council-day-5', await client.groupCoverage({ projectId, group: 'elders', claim: stole, at: { tick: 5 } }));
print('tam-day-5', await client.holds({ projectId, holder: 'tam', claim: took, at: { tick: 5 } }));
await client.close();
Program.cs
var spread = new Dictionary<string, object[][]>();
foreach (var day in new long[] { 1, 2, 3, 4, 5 })
{
    var held = VersionsAt(day);
    spread[day.ToString()] = villagers.Keys.Select(who => held.TryGetValue(who, out var story)
        ? new object[] { who, ladder[story.Rung].Predicate, story.Confidence }
        : new object[] { who, "", 0 }).ToArray();
}
Print("spread", spread);

Print("whoHolds-stole", engine.WhoHolds(new WhoHoldsPayload { ProjectId = projectId, Claim = stole, At = new At { Tick = 4 } }));

var farthest = VersionsAt(4).FirstOrDefault(pair => pair.Value.Rung == 2);
if (farthest.Key is not null)
{
    var trace = engine.Trace(new RecordId { ProjectId = projectId, RecordIdValue = farthest.Value.RecordId });
    Print("trace", trace.Chain.Select(link => new
    {
        holder = (link.Record as Record.Holding)?.Holder,
        says = (link.Record as Record.Holding)?.Claim.Predicate,
        confidence = (link.Record as Record.Holding)?.Confidence,
        how = link.Acquisition?.Kind,
        from = (link.Acquisition as Acquisition.ToldBy)?.SourceHolderId,
    }));
    Print("contradictions", engine.Contradictions(new HolderAt { ProjectId = projectId, Holder = farthest.Key, At = new At { Tick = 5 } }));
}

Print("council-day-4", engine.GroupCoverage(new GroupCoveragePayload { ProjectId = projectId, Group = "elders", Claim = stole, At = new At { Tick = 4 } }));
Print("council-day-5", engine.GroupCoverage(new GroupCoveragePayload { ProjectId = projectId, Group = "elders", Claim = stole, At = new At { Tick = 5 } }));
Print("tam-day-5", engine.Holds(new HoldsPayload { ProjectId = projectId, Holder = "tam", Claim = took, At = new At { Tick = 5 } }));

Who believes he stole it

On day four, two villagers hold the middle version. Maren only suspects it: she heard it from Bess, whom she trusts by half.

Response
[
  {
    "attitude": "believes",
    "confidence": 0.9,
    "holder": "bess",
    "polarity": true,
    "recordId": "bess-heard"
  },
  {
    "attitude": "suspects",
    "confidence": 0.45,
    "holder": "maren",
    "polarity": true,
    "recordId": "maren-heard"
  }
]

Where Ida’s story came from

trace follows each told-by back to its source. Ida believes the grain was sold; Bess told her it was stolen; Tam only saw it taken.

What canon says about it

Ida’s belief contradicts the Fact that the grain was not sold. Umwelt reports it; it never corrects her.

Response
[
  {
    "holdingId": "ida-heard",
    "kind": "vs-canon",
    "otherRecordId": "canon-not-sold",
    "reason": "opposite-polarity"
  }
]

What the elders know

On day four one elder suspects the theft. On day five, after the loft, none does.

1 of 2elders hold “stole” on day 4
0 of 2on day 5, after the loft
2still believe “sold”: Ida and Rook. Rook is the ferryman who talks to the river traders.

Try next

A counter-rumorHave the elders send the truth back along the same paths with transmitBatch, and see where it arrives too late.
Let the reeve hear itDrop the rule that spares Oswin, and ask beliefsAbout what he thinks the village believes.
News that travelsGive each telling a position on a travel axis, and compare what a traveller hears with what the village knew.
Give Rook a voiceUse context.admit so a model playing Rook can only repeat what Rook holds. See The AI innkeeper.

The whole program

One file that builds the world, runs it and prints every answer on this page. The TypeScript program and the .NET one print the same answers: the script that runs them for these pages checks it.

thornmere.ts
import { at, by, claim, createClient, fact, knows, negate, witnessed } from '@kallamo/umwelt';
import type { TransmitBatchItem } from '@kallamo/umwelt';
const client = createClient({ file: 'thornmere.umw', create: true });
const projectId = 'thornmere';
const provenance = by.human();
const print = (label: string, value: unknown) => console.log(`@@${label}\n${JSON.stringify(value)}`);

const villagers = {
  tam: 'Tam, the miller’s apprentice', bess: 'Bess, the baker', wil: 'Wil, the smith',
  ida: 'Ida, the innkeeper', rook: 'Rook, the ferryman', maren: 'Maren, the herbalist',
  oswin: 'Oswin, the reeve',
};
// The world, declared once: sending the same manifest again changes nothing.
await client.worldEnsure({
  project: { id: projectId, name: 'Thornmere', timescale: 'day' },
  entityTypes: [
    { id: 'villager', name: 'Villager', kind: 'agent' },
    { id: 'council', name: 'Council', kind: 'group' },
    { id: 'thing', name: 'Thing', kind: 'other' },
  ],
  entities: [
    ...Object.entries(villagers).map(([id, name]) => ({ id, typeId: 'villager', name })),
    { id: 'elders', typeId: 'council', name: 'The council of elders' },
    { id: 'seed-grain', typeId: 'thing', name: 'The seed grain' },
    { id: 'loft', typeId: 'thing', name: 'The dry loft' },
  ],
  predicates: [
    { id: 'took', label: 'took', object: 'entity', cardinality: 'many' },
    { id: 'stole', label: 'stole', object: 'entity', cardinality: 'many' },
    { id: 'sold', label: 'sold', object: 'entity', cardinality: 'many' },
    { id: 'kept-in', label: 'is kept in', object: 'entity', cardinality: 'one' },
  ],
});
// What happened: the reeve moved the seed grain to the dry loft before the rain.
const took = claim.entity('oswin', 'took', 'seed-grain');
const stole = claim.entity('oswin', 'stole', 'seed-grain');
const sold = claim.entity('oswin', 'sold', 'seed-grain', { qualifiers: { to: 'the river traders' } });
const inLoft = claim.entity('seed-grain', 'kept-in', 'loft');
const ladder = [took, stole, sold];

await client.commit({
  projectId, attemptId: 'night-1',
  event: { id: 'night-1', type: 'night', title: 'Sacks in the dark', position: at(1), participants: ['oswin', 'tam'], provenance },
  records: [
    fact(took, { id: 'canon-took' }),
    fact(inLoft, { id: 'canon-loft' }),
    fact(negate(stole), { id: 'canon-not-stolen' }),
    fact(negate(sold), { id: 'canon-not-sold' }),
    knows('oswin', inLoft, witnessed('night-1'), { id: 'oswin-loft' }),
    knows('tam', took, witnessed('night-1'), { id: 'tam-saw', text: 'I saw the reeve carry the seed grain out of the granary at midnight.' }),
    fact(claim.entity('maren', 'umwelt:member-of', 'elders'), { id: 'maren-elder' }),
    fact(claim.entity('wil', 'umwelt:member-of', 'elders'), { id: 'wil-elder' }),
  ],
});
// How much each villager trusts the others. Your game owns these numbers; Umwelt keeps them.
const trust: [string, string, number][] = [
  ['bess', 'tam', 0.9], ['wil', 'tam', 0.6], ['ida', 'bess', 0.9], ['maren', 'bess', 0.5],
  ['ida', 'wil', 0.8], ['rook', 'ida', 0.95], ['maren', 'ida', 0.7], ['maren', 'rook', 0.4],
  ['oswin', 'wil', 0.9], ['oswin', 'maren', 0.9], ['bess', 'ida', 0.8], ['wil', 'ida', 0.7],
];
await client.dispositionSetBatch({
  projectId, attemptId: 'trust', position: at(1),
  dispositions: trust.map(([holder, target, value]) => ({ holder, target, dimension: 'trust', value })),
});
// The gossip rule lives in your code. Umwelt never decides who talks or what they believe.
const meets: Record<string, string[]> = {
  tam: ['bess', 'wil'], bess: ['tam', 'ida', 'maren'], wil: ['tam', 'ida', 'oswin'],
  ida: ['bess', 'wil', 'rook', 'maren'], rook: ['ida', 'maren'], maren: ['bess', 'ida', 'rook', 'oswin'],
  oswin: ['wil', 'maren'],
};
let seed = 12;
const random = () => {
  seed = (seed + 0x6d2b79f5) | 0;
  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};

// Who holds which version of the story, read back from Umwelt.
async function versionsAt(tick: number) {
  const held = new Map<string, { rung: number; recordId: string; confidence: number }>();
  for (const [rung, claim] of ladder.entries()) {
    for (const item of await client.whoHolds({ projectId, claim, at: { tick }, filter: { polarity: true } })) {
      held.set(item.holder, { rung, recordId: item.recordId, confidence: item.confidence });
    }
  }
  return held;
}

async function trustOf(listener: string, teller: string, tick: number) {
  const [trust] = await client.dispositions({ projectId, holder: listener, target: teller, dimension: 'trust', at: { tick } });
  return trust?.kind === 'disposition' ? Number(trust.value) : 0.5;
}

for (const day of [2, 3, 4]) {
  const held = await versionsAt(day - 1);
  const told = new Set<string>();
  const transmits: TransmitBatchItem[] = [];
  for (const [teller, story] of [...held].sort(([a], [b]) => a.localeCompare(b))) {
    if (story.confidence < 0.5) continue;                     // too unsure to repeat it
    for (const listener of meets[teller]) {
      if (listener === 'oswin' || held.has(listener) || told.has(listener)) continue;
      if (random() > 0.7) continue;                           // they did not meet today
      const rung = Math.min(2, story.rung + (random() < 0.4 ? 1 : 0));
      const confidence = Math.round(story.confidence * (await trustOf(listener, teller, day)) * 100) / 100;
      told.add(listener);
      transmits.push({
        attemptId: `day-${day}-${teller}-${listener}`,
        from: teller, to: listener, sourceRecordId: story.recordId,
        distortedClaim: rung !== story.rung ? ladder[rung] : null,
        attitude: confidence >= 0.6 ? 'believes' : 'suspects',
        confidence, recordId: `${listener}-heard`,
      });
    }
  }
  if (!transmits.length) continue;                            // a quiet day
  // What every telling of the day has in common is said once. They believe what they say.
  const outcomes = await client.transmitBatch({ shared: { projectId, intent: 'honest', position: at(day) }, transmits });
  if (day === 2) print('day-2', outcomes);
}
// Day 5: the reeve opens the loft. Whoever sees the grain drops the theft story; nobody else does.
const present = ['wil', 'maren', 'bess'];
await client.witness({
  projectId, attemptId: 'day-5',
  event: { id: 'loft-opened', type: 'scene', title: 'The loft is opened', position: at(5), participants: ['oswin', ...present], provenance },
  claims: [{ claim: inLoft }],
  witnesses: present.map((holder) => ({ holder })),
});
const before = await versionsAt(5);
const disproved = present.filter((who) => (before.get(who)?.rung ?? 0) > 0).map((who) => before.get(who)!.recordId);
if (disproved.length) {
  await client.transitionBatch({ projectId, attemptId: 'abandon', position: at(5), recordIds: disproved, status: 'abandoned' });
}
const spread: Record<string, (string | number)[][]> = {};
for (const day of [1, 2, 3, 4, 5]) {
  const held = await versionsAt(day);
  spread[day] = Object.keys(villagers).map((who) => {
    const story = held.get(who);
    return story ? [who, ladder[story.rung].predicate, story.confidence] : [who, '', 0];
  });
}
print('spread', spread);

print('whoHolds-stole', await client.whoHolds({ projectId, claim: stole, at: { tick: 4 } }));

const farthest = [...(await versionsAt(4))].find(([, story]) => story.rung === 2);
if (farthest) {
  const trace = await client.trace({ projectId, recordId: farthest[1].recordId });
  print('trace', trace.chain.map(({ record, acquisition }) => ({
    holder: record.kind === 'holding' ? record.holder : null,
    says: record.kind === 'holding' ? record.claim.predicate : null,
    confidence: record.kind === 'holding' ? record.confidence : null,
    how: acquisition?.kind,
    from: acquisition?.kind === 'told-by' ? acquisition.sourceHolderId : null,
  })));
  print('contradictions', await client.contradictions({ projectId, holder: farthest[0], at: { tick: 5 } }));
}

print('council-day-4', await client.groupCoverage({ projectId, group: 'elders', claim: stole, at: { tick: 4 } }));
print('council-day-5', await client.groupCoverage({ projectId, group: 'elders', claim: stole, at: { tick: 5 } }));
print('tam-day-5', await client.holds({ projectId, holder: 'tam', claim: took, at: { tick: 5 } }));
await client.close();
Program.cs
using System.Text.Json;
using System.Text.Json.Serialization;
using Kallamo.Umwelt;
using var engine = UmweltEngine.Create("thornmere.umw");
const string projectId = "thornmere";
var provenance = Provenance.Human();
var json = new JsonSerializerOptions
{
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    Converters = { new JsonStringEnumConverter(JsonNamingPolicy.KebabCaseLower) },
};
void Print(string label, object? value) => Console.WriteLine($"@@{label}\n{JsonSerializer.Serialize(value, json)}");

var villagers = new Dictionary<string, string>
{
    ["tam"] = "Tam, the miller’s apprentice", ["bess"] = "Bess, the baker", ["wil"] = "Wil, the smith",
    ["ida"] = "Ida, the innkeeper", ["rook"] = "Rook, the ferryman", ["maren"] = "Maren, the herbalist",
    ["oswin"] = "Oswin, the reeve",
};
// The world, declared once: sending the same manifest again changes nothing.
engine.WorldEnsure(new EnsureWorldInput
{
    Project = new WorldProject { Id = projectId, Name = "Thornmere", Timescale = "day" },
    EntityTypes = new()
    {
        new WorldEntityType { Id = "villager", Name = "Villager", Kind = EntityKind.Agent },
        new WorldEntityType { Id = "council", Name = "Council", Kind = EntityKind.Group },
        new WorldEntityType { Id = "thing", Name = "Thing", Kind = EntityKind.Other },
    },
    Entities = villagers.Select(villager => new WorldEntity { Id = villager.Key, TypeId = "villager", Name = villager.Value })
        .Append(new WorldEntity { Id = "elders", TypeId = "council", Name = "The council of elders" })
        .Append(new WorldEntity { Id = "seed-grain", TypeId = "thing", Name = "The seed grain" })
        .Append(new WorldEntity { Id = "loft", TypeId = "thing", Name = "The dry loft" })
        .ToList(),
    Predicates = new()
    {
        new PredicateDef { Id = "took", Label = "took", Object = ObjectKind.Entity, Cardinality = Cardinality.Many },
        new PredicateDef { Id = "stole", Label = "stole", Object = ObjectKind.Entity, Cardinality = Cardinality.Many },
        new PredicateDef { Id = "sold", Label = "sold", Object = ObjectKind.Entity, Cardinality = Cardinality.Many },
        new PredicateDef { Id = "kept-in", Label = "is kept in", Object = ObjectKind.Entity, Cardinality = Cardinality.One },
    },
});
// What happened: the reeve moved the seed grain to the dry loft before the rain.
var took = Claim.Entity("oswin", "took", "seed-grain");
var stole = Claim.Entity("oswin", "stole", "seed-grain");
var sold = Claim.Entity("oswin", "sold", "seed-grain", qualifiers: new Dictionary<string, object?> { ["to"] = "the river traders" });
var inLoft = Claim.Entity("seed-grain", "kept-in", "loft");
var ladder = new[] { took, stole, sold };
var night = Position.At(1);
var witnessed = Acquired.Witnessed("night-1");

engine.Commit(new CommitInput
{
    ProjectId = projectId,
    AttemptId = "night-1",
    Event = new EventInput { Id = "night-1", Type = "night", Title = "Sacks in the dark", Position = night, Participants = new() { "oswin", "tam" }, Provenance = provenance },
    Records = new()
    {
        Records.Fact(took, id: "canon-took"),
        Records.Fact(inLoft, id: "canon-loft"),
        Records.Fact(stole.Negated(), id: "canon-not-stolen"),
        Records.Fact(sold.Negated(), id: "canon-not-sold"),
        Records.Knows("oswin", inLoft, witnessed, id: "oswin-loft"),
        Records.Knows("tam", took, witnessed, id: "tam-saw", text: "I saw the reeve carry the seed grain out of the granary at midnight."),
        Records.Fact(Claim.Entity("maren", "umwelt:member-of", "elders"), id: "maren-elder"),
        Records.Fact(Claim.Entity("wil", "umwelt:member-of", "elders"), id: "wil-elder"),
    },
});
// How much each villager trusts the others. Your game owns these numbers; Umwelt keeps them.
var trust = new (string Holder, string Target, double Value)[]
{
    ("bess", "tam", 0.9), ("wil", "tam", 0.6), ("ida", "bess", 0.9), ("maren", "bess", 0.5),
    ("ida", "wil", 0.8), ("rook", "ida", 0.95), ("maren", "ida", 0.7), ("maren", "rook", 0.4),
    ("oswin", "wil", 0.9), ("oswin", "maren", 0.9), ("bess", "ida", 0.8), ("wil", "ida", 0.7),
};
engine.DispositionSetBatch(new SetDispositionsInput
{
    ProjectId = projectId,
    AttemptId = "trust",
    Position = night,
    Dispositions = trust.Select(t => new DispositionSetting
    {
        Holder = t.Holder, Target = t.Target, Dimension = "trust", Value = JsonSerializer.SerializeToElement(t.Value),
    }).ToList(),
});
// The gossip rule lives in your code. Umwelt never decides who talks or what they believe.
var meets = new Dictionary<string, string[]>
{
    ["tam"] = new[] { "bess", "wil" }, ["bess"] = new[] { "tam", "ida", "maren" }, ["wil"] = new[] { "tam", "ida", "oswin" },
    ["ida"] = new[] { "bess", "wil", "rook", "maren" }, ["rook"] = new[] { "ida", "maren" },
    ["maren"] = new[] { "bess", "ida", "rook", "oswin" }, ["oswin"] = new[] { "wil", "maren" },
};
var seed = 12;
double Random()
{
    unchecked
    {
        seed += 0x6d2b79f5;
        var t = (seed ^ (int)((uint)seed >> 15)) * (1 | seed);
        t = (t + (t ^ (int)((uint)t >> 7)) * (61 | t)) ^ t;
        return (uint)(t ^ (int)((uint)t >> 14)) / 4294967296.0;
    }
}

// Who holds which version of the story, read back from Umwelt.
Dictionary<string, (int Rung, string RecordId, double Confidence)> VersionsAt(long tick)
{
    var held = new Dictionary<string, (int, string, double)>();
    for (var rung = 0; rung < ladder.Length; rung++)
    {
        var filter = new WhoHoldsFilter { Polarity = true };
        foreach (var item in engine.WhoHolds(new WhoHoldsPayload { ProjectId = projectId, Claim = ladder[rung], At = new At { Tick = tick }, Filter = filter }))
        {
            held[item.Holder] = (rung, item.RecordId, item.Confidence);
        }
    }
    return held;
}

double TrustOf(string listener, string teller, long tick)
{
    var trust = engine.Dispositions(new DispositionsPayload { ProjectId = projectId, Holder = listener, Target = teller, Dimension = "trust", At = new At { Tick = tick } });
    return trust.OfType<Record.Disposition>().FirstOrDefault()?.Value.GetDouble() ?? 0.5;
}

foreach (var day in new long[] { 2, 3, 4 })
{
    var held = VersionsAt(day - 1);
    var told = new HashSet<string>();
    var transmits = new List<TransmitBatchItem>();
    foreach (var (teller, story) in held.OrderBy(pair => pair.Key, StringComparer.Ordinal))
    {
        if (story.Confidence < 0.5) continue;                    // too unsure to repeat it
        foreach (var listener in meets[teller])
        {
            if (listener == "oswin" || held.ContainsKey(listener) || told.Contains(listener)) continue;
            if (Random() > 0.7) continue;                        // they did not meet today
            var rung = Math.Min(2, story.Rung + (Random() < 0.4 ? 1 : 0));
            var confidence = Math.Round(story.Confidence * TrustOf(listener, teller, day) * 100, MidpointRounding.AwayFromZero) / 100;
            told.Add(listener);
            transmits.Add(new TransmitBatchItem
            {
                AttemptId = $"day-{day}-{teller}-{listener}",
                From = teller, To = listener, SourceRecordId = story.RecordId,
                DistortedClaim = rung != story.Rung ? ladder[rung] : null,
                Attitude = confidence >= 0.6 ? Attitude.Believes : Attitude.Suspects,
                Confidence = confidence, RecordId = $"{listener}-heard",
            });
        }
    }
    if (transmits.Count == 0) continue;                          // a quiet day
    // What every telling of the day has in common is said once. They believe what they say.
    var outcomes = engine.TransmitBatch(new TransmitBatchPayload
    {
        Shared = new TransmitShared { ProjectId = projectId, Intent = Intent.Honest, Position = Position.At(day) },
        Transmits = transmits,
    });
    if (day == 2) Print("day-2", outcomes);
}
// Day 5: the reeve opens the loft. Whoever sees the grain drops the theft story; nobody else does.
var present = new List<string> { "wil", "maren", "bess" };
engine.Witness(new WitnessInput
{
    ProjectId = projectId,
    AttemptId = "day-5",
    Event = new EventInput
    {
        Id = "loft-opened", Type = "scene", Title = "The loft is opened", Position = Position.At(5),
        Participants = present.Prepend("oswin").ToList(), Provenance = provenance,
    },
    Claims = new() { new WitnessedClaim { Claim = inLoft } },
    Witnesses = present.Select(holder => new Witness { Holder = holder }).ToList(),
});
var before = VersionsAt(5);
var disproved = present.Where(who => before.TryGetValue(who, out var story) && story.Rung > 0).Select(who => before[who].RecordId).ToList();
if (disproved.Count > 0)
{
    engine.TransitionBatch(new TransitionBatchInput
    {
        ProjectId = projectId, AttemptId = "abandon", Position = Position.At(5), RecordIds = disproved, Status = TransitionStatus.Abandoned,
    });
}
var spread = new Dictionary<string, object[][]>();
foreach (var day in new long[] { 1, 2, 3, 4, 5 })
{
    var held = VersionsAt(day);
    spread[day.ToString()] = villagers.Keys.Select(who => held.TryGetValue(who, out var story)
        ? new object[] { who, ladder[story.Rung].Predicate, story.Confidence }
        : new object[] { who, "", 0 }).ToArray();
}
Print("spread", spread);

Print("whoHolds-stole", engine.WhoHolds(new WhoHoldsPayload { ProjectId = projectId, Claim = stole, At = new At { Tick = 4 } }));

var farthest = VersionsAt(4).FirstOrDefault(pair => pair.Value.Rung == 2);
if (farthest.Key is not null)
{
    var trace = engine.Trace(new RecordId { ProjectId = projectId, RecordIdValue = farthest.Value.RecordId });
    Print("trace", trace.Chain.Select(link => new
    {
        holder = (link.Record as Record.Holding)?.Holder,
        says = (link.Record as Record.Holding)?.Claim.Predicate,
        confidence = (link.Record as Record.Holding)?.Confidence,
        how = link.Acquisition?.Kind,
        from = (link.Acquisition as Acquisition.ToldBy)?.SourceHolderId,
    }));
    Print("contradictions", engine.Contradictions(new HolderAt { ProjectId = projectId, Holder = farthest.Key, At = new At { Tick = 5 } }));
}

Print("council-day-4", engine.GroupCoverage(new GroupCoveragePayload { ProjectId = projectId, Group = "elders", Claim = stole, At = new At { Tick = 4 } }));
Print("council-day-5", engine.GroupCoverage(new GroupCoveragePayload { ProjectId = projectId, Group = "elders", Claim = stole, At = new At { Tick = 5 } }));
Print("tam-day-5", engine.Holds(new HoldsPayload { ProjectId = projectId, Holder = "tam", Claim = took, At = new At { Tick = 5 } }));

More recipes

Was this page helpful?