diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Forms.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Forms.cs
index 8727e329e..f74eca8fd 100644
--- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Forms.cs
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Forms.cs
@@ -113,12 +113,19 @@ public sealed partial class BeyondFactsLabStore
throw new InvalidDataException("This version of AI Studio cannot open the form.", exception);
}
- if (form is null || form.SchemaVersion is < 1 or > BflStepFormData.CURRENT_SCHEMA_VERSION || IsNull(form.Values) || form.CardSort is { } cardSort && IsBroken(cardSort))
+ if (form is null || form.SchemaVersion is < 1 or > BflStepFormData.CURRENT_SCHEMA_VERSION || IsNull(form.Values) || form.CardSort is { } cardSort && IsBroken(cardSort) || form.Simulation is { } simulation && IsBroken(simulation))
throw new InvalidDataException("This version of AI Studio cannot open the form.");
return form;
}
+ ///
+ /// Tells whether a stored simulation lacks one of its parts, which JSON can make null, or names a mode this version
+ /// does not know.
+ ///
+ /// The simulation.
+ private static bool IsBroken(BflSimulation simulation) => !Enum.IsDefined(simulation.Mode) || IsNull(simulation.People) || simulation.People.Any(IsNull);
+
///
/// Tells whether a stored card sort lacks one of its parts, which JSON can make null.
///
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflEffectiveSimulation.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflEffectiveSimulation.cs
new file mode 100644
index 000000000..8826a5500
--- /dev/null
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflEffectiveSimulation.cs
@@ -0,0 +1,35 @@
+namespace AIStudio.Assistants.BeyondFactsLab;
+
+///
+/// Whom the chat of the step "Test situation" of the BeyondFacts Lab actually plays: the mode and the people which
+/// count of what the person chose.
+///
+///
+/// A chosen person counts while they are a candidate, see BflSimulationCandidates, and only as many as the mode allows,
+/// in the order of the list of participants. When nobody of the chosen people counts, the chat plays generic
+/// counterparts, as it did before the person chose a mode.
+///
+/// The mode in effect.
+/// The research on the people the chat plays, in the order of the list; empty for generic counterparts.
+public sealed record BflEffectiveSimulation(BflSimulationMode Mode, IReadOnlyList People)
+{
+ ///
+ /// The simulation with generic counterparts, no specific person.
+ ///
+ public static readonly BflEffectiveSimulation GENERIC = new(BflSimulationMode.GENERIC, []);
+
+ ///
+ /// Gets whom the chat plays of what the person chose.
+ ///
+ /// What the person chose; null when they chose nothing.
+ /// The people the chat may play, in the order of the list, see BflSimulationCandidates.
+ /// The simulation in effect.
+ public static BflEffectiveSimulation Of(BflSimulation? simulation, IReadOnlyList candidates)
+ {
+ if (simulation is null || !simulation.Mode.HasPeople)
+ return GENERIC;
+
+ var people = candidates.Where(candidate => simulation.HasChosen(candidate.Name)).Take(simulation.Mode.MaxPeople).ToList();
+ return people.Count is 0 ? GENERIC : new(simulation.Mode, people);
+ }
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulation.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulation.cs
new file mode 100644
index 000000000..c52b055e5
--- /dev/null
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulation.cs
@@ -0,0 +1,31 @@
+namespace AIStudio.Assistants.BeyondFactsLab;
+
+///
+/// Whom the person chose for the chat of the step "Test situation" of the BeyondFacts Lab to play: the mode, and the
+/// people from the research on single participants.
+///
+///
+/// The choice belongs to the details of its step and is stored with them, see BflStepFormData.Simulation. It is the
+/// opt-in of the person: the chat plays a real person only while the person chose them here. What counts is the
+/// effective simulation, see BflEffectiveSimulation: a name which is no longer a candidate stays stored, but counts no
+/// longer, and so do the names beyond what the mode allows.
+///
+public sealed class BflSimulation
+{
+ ///
+ /// Gets or sets the mode.
+ ///
+ public BflSimulationMode Mode { get; set; } = BflSimulationMode.GENERIC;
+
+ ///
+ /// Gets or sets the names of the people the person chose, as the list of participants gives them, in no particular
+ /// order.
+ ///
+ public List People { get; set; } = [];
+
+ ///
+ /// Tells whether the person chose a person, regardless of case and of the spaces between the words.
+ ///
+ /// The name of the person.
+ public bool HasChosen(string name) => this.People.Any(chosen => string.Equals(BflParticipant.NameKey(chosen), BflParticipant.NameKey(name), StringComparison.Ordinal));
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationBudget.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationBudget.cs
new file mode 100644
index 000000000..df4fa07c7
--- /dev/null
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationBudget.cs
@@ -0,0 +1,52 @@
+using AIStudio.Models;
+
+namespace AIStudio.Assistants.BeyondFactsLab;
+
+///
+/// Estimates whether the context window of a model holds the simulation the person chose in the step "Test situation"
+/// of the BeyondFacts Lab.
+///
+///
+/// The prompt of the step carries the dossier of every person the chat plays, in full: unlike the material, a dossier
+/// is never shortened, since the chat must not play a person after half of what they said. The estimate adds the
+/// dossiers to the smallest window the lab accepts at all, which leaves room for the fixed part of the prompt, the
+/// details, and the conversation, and counts the characters of a dossier the way the budget of the material does. The
+/// material then gets what is left, see BflMaterialBudget.
+///
+public static class BflSimulationBudget
+{
+ ///
+ /// Estimates how many tokens the dossiers of the people the chat plays take in the prompt.
+ ///
+ /// The research on the people the chat plays.
+ /// The estimated tokens.
+ public static int DossierTokens(IReadOnlyList people)
+ {
+ var characters = people.Sum(person => person.Stages.Sum(stage => stage.Research.Text.Trim().Length));
+ return (characters + BflMaterialBudget.CHARACTERS_PER_TOKEN - 1) / BflMaterialBudget.CHARACTERS_PER_TOKEN;
+ }
+
+ ///
+ /// Estimates how many tokens a model needs to read at once for a simulation.
+ ///
+ /// The research on the people the chat plays.
+ /// The estimated tokens.
+ public static int RequiredTokens(IReadOnlyList people) => BflModelRequirements.MINIMUM_CONTEXT_TOKENS + DossierTokens(people);
+
+ ///
+ /// Checks whether the context window of a model is likely to hold a simulation.
+ ///
+ /// The simulation in effect.
+ /// The context window of the model, as it ships.
+ /// Whether the window holds the simulation, nobody knows, or it is likely too small.
+ public static BflSimulationFit Check(BflEffectiveSimulation simulation, ContextWindow contextWindow)
+ {
+ if (simulation.People.Count is 0)
+ return BflSimulationFit.FITS;
+
+ if (!contextWindow.IsKnown)
+ return BflSimulationFit.UNKNOWN_CONTEXT;
+
+ return contextWindow.DefaultTokens < RequiredTokens(simulation.People) ? BflSimulationFit.TOO_SMALL : BflSimulationFit.FITS;
+ }
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationCandidates.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationCandidates.cs
new file mode 100644
index 000000000..5baaf0abf
--- /dev/null
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationCandidates.cs
@@ -0,0 +1,35 @@
+namespace AIStudio.Assistants.BeyondFactsLab;
+
+///
+/// Decides whom of the participants the chat of the step "Test situation" of the BeyondFacts Lab may play.
+///
+///
+/// Only a person with a documented public position qualifies, so that the chat never makes up what somebody stands
+/// for: the person stands on the current list of participants, and the stage "Positions" of the research on them is
+/// done and holds text. For an event which is not public, no research counts, see BflEventVisibility, and nobody
+/// qualifies. The prompt of the simulation still lets the chat refuse to play a person whose dossier documents no
+/// position after all, since only the model reads what the text says.
+///
+public static class BflSimulationCandidates
+{
+ ///
+ /// Gets the people the chat of the step "Test situation" may play.
+ ///
+ /// The forms of the preparation, by their steps.
+ /// The research on the topics, with the list of participants.
+ /// The research on every person stored for the preparation.
+ /// The research on each person who qualifies, in the order of the list.
+ public static IReadOnlyList Of(IReadOnlyDictionary forms, IReadOnlyDictionary topics, IReadOnlyList people)
+ {
+ if (BflEventVisibility.IsNotPublic(forms))
+ return [];
+
+ return [.. BflStepResearch.Of(topics, people).People.Where(HasPositions)];
+ }
+
+ ///
+ /// Tells whether the research on a person documents public positions: its stage "Positions" is done and holds text.
+ ///
+ /// The research on the person.
+ public static bool HasPositions(BflPersonResearch person) => person.StageOf(BflPersonResearchStage.POSITIONS) is { } positions && positions.Text.Trim().Length > 0;
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationFit.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationFit.cs
new file mode 100644
index 000000000..bf60e368b
--- /dev/null
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationFit.cs
@@ -0,0 +1,23 @@
+namespace AIStudio.Assistants.BeyondFactsLab;
+
+///
+/// Whether the context window of a model is likely to hold the simulation the person chose in the step "Test
+/// situation" of the BeyondFacts Lab, see BflSimulationBudget.
+///
+public enum BflSimulationFit
+{
+ ///
+ /// The window is known and holds the simulation, or the simulation plays nobody from the research.
+ ///
+ FITS,
+
+ ///
+ /// Nobody knows the window of the model, so the lab cannot tell; it warns the person and lets the chat run.
+ ///
+ UNKNOWN_CONTEXT,
+
+ ///
+ /// The window is known and likely too small; the chat does not send.
+ ///
+ TOO_SMALL,
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationMode.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationMode.cs
new file mode 100644
index 000000000..1a87eb005
--- /dev/null
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationMode.cs
@@ -0,0 +1,31 @@
+namespace AIStudio.Assistants.BeyondFactsLab;
+
+///
+/// Whom the chat of the step "Test situation" of the BeyondFacts Lab plays: generic counterparts, or people from the
+/// research on single participants in a format of the event.
+///
+///
+/// The names are stored, so they never change. A new format joins as a new member, see BflSimulationModeExtensions.
+///
+public enum BflSimulationMode
+{
+ ///
+ /// Generic counterparts, no specific person: the step as it was before the lab simulated people.
+ ///
+ GENERIC,
+
+ ///
+ /// One person from the research, after their dossier.
+ ///
+ SINGLE_PERSON,
+
+ ///
+ /// A panel discussion with up to three people from the research.
+ ///
+ PANEL_DISCUSSION,
+
+ ///
+ /// A press conference with up to five people from the research.
+ ///
+ PRESS_CONFERENCE,
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationModeExtensions.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationModeExtensions.cs
new file mode 100644
index 000000000..769c357e4
--- /dev/null
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationModeExtensions.cs
@@ -0,0 +1,47 @@
+namespace AIStudio.Assistants.BeyondFactsLab;
+
+///
+/// What the BeyondFacts Lab shows and allows for a mode of the simulation in the step "Test situation".
+///
+public static class BflSimulationModeExtensions
+{
+ private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(BflSimulationModeExtensions).Namespace, nameof(BflSimulationModeExtensions));
+
+ /// The mode.
+ extension(BflSimulationMode mode)
+ {
+ ///
+ /// Gets the name of the mode, as the selection shows it.
+ ///
+ public string Title => mode switch
+ {
+ BflSimulationMode.SINGLE_PERSON => TB("One person from the research"),
+ BflSimulationMode.PANEL_DISCUSSION => TB("Panel discussion with up to three people"),
+ BflSimulationMode.PRESS_CONFERENCE => TB("Press conference with up to five people"),
+
+ _ => TB("Generic counterparts, no specific person"),
+ };
+
+ ///
+ /// Gets how many people from the research the chat plays at most in this mode; none for generic counterparts.
+ ///
+ public int MaxPeople => mode switch
+ {
+ BflSimulationMode.SINGLE_PERSON => 1,
+ BflSimulationMode.PANEL_DISCUSSION => 3,
+ BflSimulationMode.PRESS_CONFERENCE => 5,
+
+ _ => 0,
+ };
+
+ ///
+ /// Gets whether the chat plays people from the research in this mode.
+ ///
+ public bool HasPeople => mode.MaxPeople > 0;
+
+ ///
+ /// Gets every mode, in the order the selection shows them.
+ ///
+ public static IReadOnlyList All => Enum.GetValues();
+ }
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStaleness.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStaleness.cs
index 9314f70e7..3efac8e56 100644
--- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStaleness.cs
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStaleness.cs
@@ -96,7 +96,8 @@ public static class BflStaleness
///
/// Line endings and spaces around a value do not count, since they change nothing the model reads. The card sort
/// counts as a detail of its step; without one, or with an empty one, the fingerprint is the same as before the lab
- /// had a card sort, so that no confirmation of an older preparation turns into "Details changed".
+ /// had a card sort, so that no confirmation of an older preparation turns into "Details changed". The same holds for
+ /// the simulation of the step "Test situation" with generic counterparts.
///
/// The form.
/// The fingerprint, in hexadecimal digits.
@@ -115,9 +116,28 @@ public static class BflStaleness
if (form.CardSort is { IsEmpty: false } cardSort)
AppendCardSort(text, cardSort);
+ if (form.Simulation is { Mode.HasPeople: true } simulation)
+ AppendSimulation(text, simulation);
+
return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text.ToString())));
}
+ ///
+ /// Appends a simulation with people to the text a fingerprint is computed from.
+ ///
+ ///
+ /// Generic counterparts add nothing, so that the fingerprint stays the same as before the lab simulated people. The
+ /// people count as a set, by the keys of their names: the order the person chose them in means nothing.
+ ///
+ /// The text so far.
+ /// The simulation.
+ private static void AppendSimulation(StringBuilder text, BflSimulation simulation)
+ {
+ text.Append(SEPARATOR).Append("simulation").Append(SEPARATOR).Append(simulation.Mode.ToString()).Append(SEPARATOR);
+ foreach (var key in simulation.People.Select(BflParticipant.NameKey).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal))
+ text.Append(key).Append(SEPARATOR);
+ }
+
///
/// Appends a card sort to the text a fingerprint is computed from.
///
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepFormData.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepFormData.cs
index 72c7be123..62da9a706 100644
--- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepFormData.cs
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepFormData.cs
@@ -7,14 +7,15 @@ namespace AIStudio.Assistants.BeyondFactsLab;
///
///
/// The values are stored under the IDs of the fields of BflFieldCatalog. A value of a field this version does not
-/// know, e.g., one a newer version added, stays as it is and is written back unchanged. Version 2 added the card sort.
+/// know, e.g., one a newer version added, stays as it is and is written back unchanged. Version 2 added the card sort,
+/// version 3 the simulation.
///
public sealed class BflStepFormData
{
///
/// The version of the stored form this version of AI Studio writes. Raise it with every new property.
///
- public const int CURRENT_SCHEMA_VERSION = 2;
+ public const int CURRENT_SCHEMA_VERSION = 3;
///
/// The character which separates the options of a field with several choices.
@@ -40,6 +41,15 @@ public sealed class BflStepFormData
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public BflCardSort? CardSort { get; set; }
+ ///
+ /// Gets or sets whom the chat of the step "Test situation" plays, see BflSimulation.
+ ///
+ ///
+ /// Null for every other step, and until the person chooses a mode there; the file then leaves it out.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public BflSimulation? Simulation { get; set; }
+
///
/// Gets the value of a field, or an empty string when the field has none.
///
diff --git a/app/Tests/BeyondFactsLab/BeyondFactsLabStoreTests.cs b/app/Tests/BeyondFactsLab/BeyondFactsLabStoreTests.cs
index bf85160de..e65606c6c 100644
--- a/app/Tests/BeyondFactsLab/BeyondFactsLabStoreTests.cs
+++ b/app/Tests/BeyondFactsLab/BeyondFactsLabStoreTests.cs
@@ -405,6 +405,54 @@ public sealed class BeyondFactsLabStoreTests
await Assert.ThrowsAsync(async () => await store.LoadFormAsync(created.ProjectId, BflStep.VALUES_AND_PERSPECTIVES));
}
+ [Test]
+ public async Task ASimulationIsStoredWithTheFormOfItsStep()
+ {
+ var store = this.CreateStore();
+ var created = await store.CreateAsync("Panel");
+ var form = new BflStepFormData { Simulation = new() { Mode = BflSimulationMode.PANEL_DISCUSSION, People = ["Eva Klein", "Frank Ott"] } };
+
+ await store.SaveFormAsync(created.ProjectId, BflStep.TEST_SITUATION, form);
+ var json = await File.ReadAllTextAsync(Path.Combine(this.RootDirectory, created.ProjectId.ToString("D"), "steps", "06-test", "form.json"));
+ var loaded = (await this.CreateStore().LoadFormAsync(created.ProjectId, BflStep.TEST_SITUATION)).Simulation;
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(json, Does.Contain("\"PANEL_DISCUSSION\""), "The mode is stored by its name, which never changes.");
+ Assert.That(loaded, Is.Not.Null);
+ Assert.That(loaded!.Mode, Is.EqualTo(BflSimulationMode.PANEL_DISCUSSION));
+ Assert.That(loaded.People, Is.EqualTo(new[] { "Eva Klein", "Frank Ott" }));
+ }
+ }
+
+ [Test]
+ public async Task AFormOfTheSecondVersionIsStillOpenedWithoutASimulation()
+ {
+ var store = this.CreateStore();
+ var created = await store.CreateAsync("Panel");
+ await this.WriteFormFileAsync(created.ProjectId, "06-test", """{ "schemaVersion": 2, "values": { "test_notes": "Text" } }""");
+
+ var form = await store.LoadFormAsync(created.ProjectId, BflStep.TEST_SITUATION);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(form.Get(BflFieldCatalog.TEST_NOTES), Is.EqualTo("Text"));
+ Assert.That(form.Simulation, Is.Null);
+ }
+ }
+
+ [TestCase("""{ "schemaVersion": 3, "values": {}, "simulation": { "mode": "PANEL_DISCUSSION", "people": null } }""")]
+ [TestCase("""{ "schemaVersion": 3, "values": {}, "simulation": { "mode": "PANEL_DISCUSSION", "people": [ null ] } }""")]
+ [TestCase("""{ "schemaVersion": 3, "values": {}, "simulation": { "mode": 42, "people": [] } }""")]
+ public async Task AFormWithABrokenSimulationIsNotOpened(string json)
+ {
+ var store = this.CreateStore();
+ var created = await store.CreateAsync("Panel");
+ await this.WriteFormFileAsync(created.ProjectId, "06-test", json);
+
+ await Assert.ThrowsAsync(async () => await store.LoadFormAsync(created.ProjectId, BflStep.TEST_SITUATION));
+ }
+
[Test]
public async Task AStepWithoutAFormGetsAnEmptyOne()
{
@@ -463,7 +511,7 @@ public sealed class BeyondFactsLabStoreTests
[Test]
public async Task AFormOfANewerVersionIsNotOverwritten()
{
- const string NEWER = """{ "schemaVersion": 3, "values": {} }""";
+ const string NEWER = """{ "schemaVersion": 4, "values": {} }""";
var store = this.CreateStore();
var created = await store.CreateAsync("Panel");
var formPath = await this.WriteFormFileAsync(created.ProjectId, "05-strategy", NEWER);
diff --git a/app/Tests/BeyondFactsLab/BflSimulationTests.cs b/app/Tests/BeyondFactsLab/BflSimulationTests.cs
new file mode 100644
index 000000000..714ba59e4
--- /dev/null
+++ b/app/Tests/BeyondFactsLab/BflSimulationTests.cs
@@ -0,0 +1,173 @@
+using AIStudio.Assistants.BeyondFactsLab;
+using AIStudio.Models;
+
+namespace AIStudio.Tests.BeyondFactsLab;
+
+///
+/// Checks whom the chat of the step "Test situation" of the BeyondFacts Lab may play, whom it plays of what the person
+/// chose, and whether a model reads enough for it.
+///
+[TestFixture]
+public sealed class BflSimulationTests
+{
+ private static readonly DateTimeOffset CREATED_AT_UTC = new(2026, 11, 15, 10, 0, 0, TimeSpan.Zero);
+
+ [Test]
+ public void OnlyAListedPersonWithDocumentedPositionsQualifies()
+ {
+ var forms = Forms();
+ BflPersonResearch[] people =
+ [
+ Person(forms, "Eva Klein", BflPersonResearchStage.All),
+ Person(forms, "Frank Ott", [BflPersonResearchStage.PROFILE]),
+ Person(forms, "Gina Roth", [BflPersonResearchStage.PROFILE, BflPersonResearchStage.POSITIONS], positionsText: " "),
+ Person(forms, "Nicht Gelistet", BflPersonResearchStage.All)
+ ];
+
+ var candidates = BflSimulationCandidates.Of(forms, Topics(forms, "Eva Klein", "Frank Ott", "Gina Roth"), people);
+
+ Assert.That(candidates.Select(person => person.Name), Is.EqualTo(new[] { "Eva Klein" }));
+ }
+
+ [Test]
+ public void TheCandidatesComeInTheOrderOfTheList()
+ {
+ var forms = Forms();
+ BflPersonResearch[] people = [Person(forms, "Eva Klein", BflPersonResearchStage.All), Person(forms, "Frank Ott", BflPersonResearchStage.All)];
+
+ var candidates = BflSimulationCandidates.Of(forms, Topics(forms, "Frank Ott", "Eva Klein"), people);
+
+ Assert.That(candidates.Select(person => person.Name), Is.EqualTo(new[] { "Frank Ott", "Eva Klein" }));
+ }
+
+ [Test]
+ public void NobodyQualifiesForAnEventWhichIsNotPublic()
+ {
+ var forms = Forms();
+ BflPersonResearch[] people = [Person(forms, "Eva Klein", BflPersonResearchStage.All)];
+ var topics = Topics(forms, "Eva Klein");
+ forms[BflStep.FRAMEWORK].Set(BflFieldCatalog.EVENT_VISIBILITY, BflFieldCatalog.EVENT_NOT_PUBLIC);
+
+ Assert.That(BflSimulationCandidates.Of(forms, topics, people), Is.Empty);
+ }
+
+ [Test]
+ public void GenericCounterpartsPlayNobody()
+ {
+ var candidates = Candidates("Eva Klein");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(BflEffectiveSimulation.Of(null, candidates), Is.SameAs(BflEffectiveSimulation.GENERIC));
+ Assert.That(BflEffectiveSimulation.Of(new() { Mode = BflSimulationMode.GENERIC, People = ["Eva Klein"] }, candidates), Is.SameAs(BflEffectiveSimulation.GENERIC));
+ }
+ }
+
+ [TestCase(BflSimulationMode.SINGLE_PERSON, 1)]
+ [TestCase(BflSimulationMode.PANEL_DISCUSSION, 3)]
+ [TestCase(BflSimulationMode.PRESS_CONFERENCE, 5)]
+ public void AModePlaysAtMostItsNumberOfPeopleInTheOrderOfTheList(BflSimulationMode mode, int expected)
+ {
+ string[] names = ["Anna Arendt", "Bruno Berg", "Clara Cramer", "David Dorn", "Eva Klein", "Frank Ott"];
+ var candidates = Candidates(names);
+ var simulation = new BflSimulation { Mode = mode, People = [.. names.Reverse()] };
+
+ var effective = BflEffectiveSimulation.Of(simulation, candidates);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(mode.MaxPeople, Is.EqualTo(expected));
+ Assert.That(effective.Mode, Is.EqualTo(mode));
+ Assert.That(effective.People.Select(person => person.Name), Is.EqualTo(names.Take(expected)));
+ }
+ }
+
+ [Test]
+ public void APersonWhoIsNoLongerACandidateCountsNoLonger()
+ {
+ var candidates = Candidates("Eva Klein", "Frank Ott");
+ var simulation = new BflSimulation { Mode = BflSimulationMode.PANEL_DISCUSSION, People = ["Nicht Mehr Gelistet", "frank ott"] };
+
+ var effective = BflEffectiveSimulation.Of(simulation, candidates);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(effective.People.Select(person => person.Name), Is.EqualTo(new[] { "Frank Ott" }));
+ Assert.That(simulation.People, Has.Count.EqualTo(2), "What the person chose stays stored.");
+ }
+ }
+
+ [Test]
+ public void AModeWithoutAnyChosenCandidateFallsBackToGenericCounterparts()
+ {
+ var candidates = Candidates("Eva Klein");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(BflEffectiveSimulation.Of(new() { Mode = BflSimulationMode.SINGLE_PERSON }, candidates), Is.SameAs(BflEffectiveSimulation.GENERIC));
+ Assert.That(BflEffectiveSimulation.Of(new() { Mode = BflSimulationMode.PRESS_CONFERENCE, People = ["Frank Ott"] }, candidates), Is.SameAs(BflEffectiveSimulation.GENERIC));
+ }
+ }
+
+ [Test]
+ public void TheDossiersAreCountedWithTheCharactersPerTokenOfTheMaterial()
+ {
+ var person = new BflPersonResearch { Name = "Eva Klein" };
+ person.Set(BflPersonResearchStage.PROFILE, new BflResearch { Text = new string('a', 4_000), CreatedAtUtc = CREATED_AT_UTC });
+ person.Set(BflPersonResearchStage.POSITIONS, new BflResearch { Text = $" {new string('b', 3_999)}\n", CreatedAtUtc = CREATED_AT_UTC });
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(BflSimulationBudget.DossierTokens([person]), Is.EqualTo(2_000));
+ Assert.That(BflSimulationBudget.RequiredTokens([person]), Is.EqualTo(BflModelRequirements.MINIMUM_CONTEXT_TOKENS + 2_000));
+ }
+ }
+
+ [Test]
+ public void AKnownWindowWhichIsTooSmallRefusesAndAnUnknownOneWarns()
+ {
+ var person = new BflPersonResearch { Name = "Eva Klein" };
+ person.Set(BflPersonResearchStage.POSITIONS, new BflResearch { Text = new string('a', 40_000), CreatedAtUtc = CREATED_AT_UTC });
+ var simulation = new BflEffectiveSimulation(BflSimulationMode.SINGLE_PERSON, [person]);
+ var required = BflSimulationBudget.RequiredTokens([person]);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(BflSimulationBudget.Check(simulation, ContextWindow.Of(required - 1)), Is.EqualTo(BflSimulationFit.TOO_SMALL));
+ Assert.That(BflSimulationBudget.Check(simulation, ContextWindow.Of(required)), Is.EqualTo(BflSimulationFit.FITS));
+ Assert.That(BflSimulationBudget.Check(simulation, ContextWindow.UNKNOWN), Is.EqualTo(BflSimulationFit.UNKNOWN_CONTEXT));
+ Assert.That(BflSimulationBudget.Check(BflEffectiveSimulation.GENERIC, ContextWindow.Of(8_192)), Is.EqualTo(BflSimulationFit.FITS), "Generic counterparts need no room for dossiers.");
+ Assert.That(BflSimulationBudget.Check(BflEffectiveSimulation.GENERIC, ContextWindow.UNKNOWN), Is.EqualTo(BflSimulationFit.FITS));
+ }
+ }
+
+ private static List Candidates(params string[] names)
+ {
+ var forms = Forms();
+ return [.. BflSimulationCandidates.Of(forms, Topics(forms, names), [.. names.Select(name => Person(forms, name, BflPersonResearchStage.All))])];
+ }
+
+ private static BflPersonResearch Person(IReadOnlyDictionary forms, string name, IEnumerable stages, string? positionsText = null)
+ {
+ var person = new BflPersonResearch { Name = name };
+ foreach (var stage in stages)
+ person.Set(stage, BflResearch.Of(stage is BflPersonResearchStage.POSITIONS && positionsText is not null ? positionsText : $"Text {stage}", [], [], BflPromptComposer.PublicDetailsOf(forms), CREATED_AT_UTC));
+
+ return person;
+ }
+
+ private static Dictionary Topics(IReadOnlyDictionary forms, params string[] names)
+ {
+ var list = BflResearch.Of(string.Join("\n\n", names.Select(name => $"### {name}")), [], [], BflPromptComposer.PublicDetailsOf(forms), CREATED_AT_UTC);
+ list.Participants = [.. BflParticipant.ListIn(list.Text)];
+ return new() { [BflResearchTopic.PARTICIPANTS] = list };
+ }
+
+ private static Dictionary Forms()
+ {
+ var forms = BflStep.All.ToDictionary(step => step, _ => new BflStepFormData());
+ forms[BflStep.FRAMEWORK].Set(BflFieldCatalog.FORMAT, "Podium");
+ forms[BflStep.FRAMEWORK].Set(BflFieldCatalog.EVENT_TOPIC, "Klimapolitik");
+ return forms;
+ }
+}
\ No newline at end of file
diff --git a/app/Tests/BeyondFactsLab/BflStalenessTests.cs b/app/Tests/BeyondFactsLab/BflStalenessTests.cs
index 8f427aa0c..725c25854 100644
--- a/app/Tests/BeyondFactsLab/BflStalenessTests.cs
+++ b/app/Tests/BeyondFactsLab/BflStalenessTests.cs
@@ -213,6 +213,46 @@ public sealed class BflStalenessTests
Assert.That(BflStaleness.FingerprintOf(first), Is.EqualTo(BflStaleness.FingerprintOf(second)));
}
+ [Test]
+ public void WithGenericCounterpartsTheFingerprintStaysAsItWasBeforeTheSimulation()
+ {
+ var withoutSimulation = new BflStepFormData();
+ withoutSimulation.Set(BflFieldCatalog.TEST_NOTES, "Text");
+ var generic = new BflStepFormData { Simulation = new() { Mode = BflSimulationMode.GENERIC, People = ["Eva Klein"] } };
+ generic.Set(BflFieldCatalog.TEST_NOTES, "Text");
+
+ // How the lab computed the fingerprint of this form before it simulated people:
+ var before = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes($"{BflFieldCatalog.TEST_NOTES}\0Text\0")));
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(BflStaleness.FingerprintOf(withoutSimulation), Is.EqualTo(before));
+ Assert.That(BflStaleness.FingerprintOf(generic), Is.EqualTo(before));
+ }
+ }
+
+ [Test]
+ public void TheFingerprintCountsTheModeAndThePeopleOfTheSimulation()
+ {
+ var empty = new BflStepFormData();
+ var single = new BflStepFormData { Simulation = new() { Mode = BflSimulationMode.SINGLE_PERSON, People = ["Eva Klein"] } };
+ var panel = new BflStepFormData { Simulation = new() { Mode = BflSimulationMode.PANEL_DISCUSSION, People = ["Eva Klein"] } };
+ var otherPerson = new BflStepFormData { Simulation = new() { Mode = BflSimulationMode.SINGLE_PERSON, People = ["Frank Ott"] } };
+
+ var fingerprints = new[] { empty, single, panel, otherPerson }.Select(BflStaleness.FingerprintOf).ToList();
+
+ Assert.That(fingerprints, Is.Unique);
+ }
+
+ [Test]
+ public void TheOrderAndTheSpellingOfTheSimulatedPeopleIsNoChange()
+ {
+ var first = new BflStepFormData { Simulation = new() { Mode = BflSimulationMode.PANEL_DISCUSSION, People = ["Eva Klein", "Frank Ott"] } };
+ var second = new BflStepFormData { Simulation = new() { Mode = BflSimulationMode.PANEL_DISCUSSION, People = ["frank ott", "Eva Klein"] } };
+
+ Assert.That(BflStaleness.FingerprintOf(first), Is.EqualTo(BflStaleness.FingerprintOf(second)));
+ }
+
[Test]
public void AChangedCardSortWeighsLikeChangedDetails()
{