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:
co-authored by
Claude Fable 5.1
parent
366ff51a0a
commit
e67e841f8c
+93
-45
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user