Question nav: a followup is offered only once it is earned

The 0.3.34 rule listed every followup "further down" from the current
page, so the front door offered the thank-you pages before anything
was submitted, and a followup one typed in offered the ones beyond
it. The chain half was weaker than it looked too: it matched the
question the chain answered, which is a front door anyway.

Now a followup is offered only when the visitor has earned it: their
verified chain answered the alternative that leads there (the chain
index records the alternative from here on; older nodes fall back to
every recording alternative of the answered question, and nothing
beyond it), or it hangs off an earned page by a link that records
nothing. A page behind an answer they have not given is never offered.
The walk ends at any front door, so "back to the start" never fans out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-22 16:58:40 +02:00
co-authored by Claude Fable 5.1
parent 366ff51a0a
commit e67e841f8c
5 changed files with 123 additions and 68 deletions
+93 -45
View File
@@ -65,11 +65,11 @@ pub struct Question {
#[serde(default)]
pub responsible: Option<Responsible>,
/// A page that only makes sense after answering something (the
/// post-submission pages) - kept out of the question nav unless it is
/// on the visitor's path (the page their verified `?chain=` answered)
/// or further down from the page they are on, over `action` edges
/// (see `outcomes_reachable_from`). One followup path never unlocks
/// another. Unset means inferred
/// post-submission pages) - kept out of the question nav until the
/// visitor has earned it: their verified `?chain=` answered the
/// alternative that leads there, or it hangs off an earned page by
/// a link that records nothing (see `earned_followups`). Nothing
/// behind an answer they have not given is offered. Unset means inferred
/// from the file's place in the tree: files nested in a
/// subdirectory are followups unless they're the directory's
/// `index.yaml`; top-level files keep the historical default
@@ -705,45 +705,78 @@ pub fn action_target_id(
.cloned()
}
/// The outcome pages "further down" from `from`: every `is_outcome`
/// question reachable from it over `alternatives[].action` edges,
/// walking only *through* outcome pages. A front-door page (not an
/// outcome) is where the walk ends, so a "back to the start" action
/// never fans out into the whole site. `from` itself is never in the
/// result. This, plus the one page a visitor's verified chain answered,
/// decides which followups the question nav offers - one followup path
/// never unlocks another (an email-link page like redoal's `/watching`,
/// which nothing links to, stays out of every nav).
pub fn outcomes_reachable_from(
/// An alternative whose action asks nothing of the visitor: no answer
/// is recorded, so following it is navigation, not a step they have to
/// complete.
fn asks_nothing(alt: &Alternative) -> bool {
!alt.disabled && alt.record_as.is_none() && alt.self_transition.is_none()
}
/// The pages a verified answer has earned: where the answered
/// alternative sends the visitor next (a templated action resolves to
/// its dynamic pattern). When the node predates alternatives being
/// indexed, every recording alternative of that question counts - the
/// visitor did answer it, only which alternative is unknown.
pub fn unlocked_by(
questions: &std::collections::HashMap<String, Question>,
from: &str,
question_id: &str,
alternative: Option<&str>,
) -> Vec<String> {
let Some(question) = resolve_question(questions, question_id) else {
return Vec::new();
};
question
.alternatives
.iter()
.filter(|a| match alternative {
Some(name) => a.name == name,
None => a.record_as.is_some(),
})
.filter_map(|a| a.action.as_deref())
.filter_map(|action| action_target_id(questions, action))
.collect()
}
/// The followups a visitor may be offered, given the pages they have
/// earned: `seeds` (the page a verified chain sent them to) plus every
/// outcome page reachable from a seed or from `current` over
/// alternatives that ask nothing - never through one that records an
/// answer, since that answer is exactly what they have not given. A
/// front door is never in the result and ends the walk, so "back to
/// the start" never fans out into the site; standing on a page earns
/// nothing by itself beyond what it links to for free.
pub fn earned_followups(
questions: &std::collections::HashMap<String, Question>,
current: &str,
seeds: &[String],
is_outcome: &dyn Fn(&Question) -> bool,
) -> std::collections::HashSet<String> {
use std::collections::{HashSet, VecDeque};
let mut seen: HashSet<String> = HashSet::new();
let mut earned: HashSet<String> = HashSet::new();
let mut queue: VecDeque<Question> = VecDeque::new();
if let Some(q) = resolve_question(questions, from) {
for seed in seeds {
if let Some(q) = questions.get(seed).filter(|q| is_outcome(q)) {
earned.insert(seed.clone());
queue.push_back(q.clone());
}
}
if let Some(q) = resolve_question(questions, current) {
queue.push_back(q);
}
while let Some(q) = queue.pop_front() {
for action in q.alternatives.iter().filter_map(|a| a.action.as_deref()) {
for action in q.alternatives.iter().filter(|a| asks_nothing(a)).filter_map(|a| a.action.as_deref()) {
let Some(id) = action_target_id(questions, action) else {
continue;
};
// A page's own dynamic pattern (`/shape/abc` -> `/shape/[key]`)
// is itself, not somewhere further down.
if id == from || path_matches(&id, from).is_some() || seen.contains(&id) {
continue;
}
let target = &questions[&id];
if !is_outcome(target) {
if !is_outcome(target) || earned.contains(&id) || path_matches(&id, current).is_some() {
continue;
}
seen.insert(id.clone());
earned.insert(id.clone());
queue.push_back(target.clone());
}
}
seen
earned
}
/// Resolves a URL path to its question: an exact id first, else the
@@ -1198,40 +1231,55 @@ alternatives:
let files = vec![
// Front door with three outcomes, one of them templated onto
// a dynamic page.
("index.yaml".to_string(), "name: Home\nalternatives:\n - name: apply\n action: applied\n - name: subscribe\n action: subscribed\n - name: draw\n action: /shape/{curve.key}\n".to_string()),
("index.yaml".to_string(), "name: Home\nalternatives:\n - name: apply\n action: applied\n record_as: applicants\n - name: subscribe\n action: subscribed\n record_as: subscribers\n - name: draw\n action: /shape/{curve.key}\n record_as: gestures\n".to_string()),
("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()),
("subscribed.yaml".to_string(), "name: Subscribed\nfollowup: true\nalternatives:\n - name: back\n action: /\n".to_string()),
// Nested -> followup by inference; leads on to /subscribed.
("shape/[key].yaml".to_string(), "name: Shape\nalternatives:\n - name: watch\n action: /subscribed\n - name: back\n action: /\n".to_string()),
("shape/[key].yaml".to_string(), "name: Shape\nalternatives:\n - name: watch\n action: /subscribed\n record_as: subscribers\n - name: back\n action: /\n".to_string()),
// Reached only by an out-of-band link: nothing points here.
("watching.yaml".to_string(), "name: Watching\nfollowup: true\n".to_string()),
// A second, unrelated path with its own outcome.
("kontakt.yaml".to_string(), "name: Kontakt\nalternatives:\n - name: send\n action: takk\n".to_string()),
("kontakt.yaml".to_string(), "name: Kontakt\nalternatives:\n - name: send\n action: takk\n record_as: inquiries\n".to_string()),
("takk.yaml".to_string(), "name: Takk\nfollowup: true\nalternatives:\n - name: back\n action: /\n".to_string()),
];
let questions = build_questions(&files).unwrap();
let is_outcome = |q: &Question| q.is_followup();
let reach = |from: &str| outcomes_reachable_from(&questions, from, &is_outcome);
let set = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::<HashSet<_>>();
let earned = |current: &str, seeds: &[&str]| {
let seeds: Vec<String> = seeds.iter().map(|s| s.to_string()).collect();
earned_followups(&questions, current, &seeds, &is_outcome)
};
// Templated actions resolve to the dynamic page's pattern.
assert_eq!(action_target_id(&questions, "/shape/{curve.key}").as_deref(), Some("/shape/[key]"));
assert_eq!(action_target_id(&questions, "/nowhere"), None);
// From the front door: its own outcomes, and what they lead on
// to - but not the other path's outcome, nor the orphan.
assert_eq!(reach("/"), set(&["/applied", "/subscribed", "/shape/[key]"]));
// The other path only sees its own outcome. Its "back to /"
// edge ends the walk at the front door instead of fanning out.
assert_eq!(reach("/kontakt"), set(&["/takk"]));
// Standing on an outcome: nothing further down through outcomes.
assert_eq!(reach("/subscribed"), set(&[]));
assert_eq!(reach("/takk"), set(&[]));
// The email-link page reaches nothing and nothing reaches it.
assert_eq!(reach("/watching"), set(&[]));
// A concrete dynamic path resolves to its pattern and walks on;
// the pattern itself is not "further down" from its own page.
assert_eq!(reach("/shape/abc"), set(&["/subscribed"]));
// No chain, no followups: standing on the front door earns none
// of the pages its answers lead to, and standing on a followup
// one typed in earns nothing either.
assert_eq!(earned("/", &[]), set(&[]));
assert_eq!(earned("/kontakt", &[]), set(&[]));
assert_eq!(earned("/applied", &[]), set(&[]));
// A verified answer earns exactly where it sent the visitor.
assert_eq!(unlocked_by(&questions, "/", Some("apply")), vec!["/applied"]);
assert_eq!(unlocked_by(&questions, "/", Some("draw")), vec!["/shape/[key]"]);
assert_eq!(unlocked_by(&questions, "/kontakt", Some("send")), vec!["/takk"]);
assert_eq!(earned("/applied", &["/applied"]), set(&["/applied"]));
// An old node without the alternative: the answered question's
// recording alternatives, and nothing beyond that question.
let old: HashSet<String> = unlocked_by(&questions, "/", None).into_iter().collect();
assert_eq!(old, set(&["/applied", "/subscribed", "/shape/[key]"]));
assert!(!old.contains("/takk"));
// From an earned page, only links that ask nothing carry on:
// "watch" records an answer, so /subscribed is not earned by
// having drawn a shape; "back" leads to the front door, which
// is never a followup and ends the walk.
assert_eq!(earned("/shape/abc", &["/shape/[key]"]), set(&["/shape/[key]"]));
assert_eq!(earned("/takk", &["/takk"]), set(&["/takk"]));
// The email-link page is earned by nothing on the site.
assert!(!earned("/", &["/applied"]).contains("/watching"));
}
#[test]