forked from bai/curriculum-project-hub
feat(model): author 是有序列表;Info(canonical) vs RawInfo(授权 surface) 入契约
一节课可多人署名(教研组),故 canonical author 是**有序列表**而非单值。on-disk 形式接受裸字符串(单作者)或数组(多作者),但此"字符串或数组"二态**只活在加载边界**: RawInfo 经归一化折叠成 canonical Info,其后系统只见 List。 Lean 母本(ADR-0008 范围):新增 Model/Info.lean,把 Info(canonical,authors: List String)与 RawInfo/RawAuthor(授权便利 surface)+ 归一化 RawInfo.toInfo 钉死—— canonical 接收端恒为列表,raw 形式不泄漏。ADR-0008 [info] 块补 author-as-list 决策。 impl 对齐:cph-model Info.author: Option<String> → authors: Vec<String>;RawAuthor untagged enum(One|Many)+ into_vec 归一化;augmented manifest 发射 author 数组 (typst document(author:) 与模板已接受 string/array)。doc 两端互引 spec。 测试:load.rs 单作者→单元素列表;target-configs fixture 加数组作者断言多作者。 验证:lake build 绿(25 jobs);cargo test 全绿;clippy 静默;TH-141(单字符串作者) check+build PDF 无回归。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -211,12 +211,23 @@ pub struct Project {
|
||||
}
|
||||
|
||||
/// `[info]` table (passed through to render targets verbatim).
|
||||
///
|
||||
/// **Mirrors `Spec.Courseware.Info`** in the Lean semantic master
|
||||
/// (`spec/Spec/Courseware/Model/Info.lean`): the *canonical* model whose
|
||||
/// `authors` is always a list. The authoring-surface form (string-or-array
|
||||
/// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this
|
||||
/// at the load boundary — mirroring the Lean `RawInfo` / `RawAuthor` split. No
|
||||
/// CI gate enforces this alignment (repo constitution); it is kept greppable.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Info {
|
||||
/// Lesson title.
|
||||
pub title: String,
|
||||
/// Author, optional.
|
||||
pub author: Option<String>,
|
||||
/// Authors, in declared order. A lesson can have several (a teaching group),
|
||||
/// so this is a list, not a single name. Empty when `[info]` declares no
|
||||
/// `author`. The on-disk `author` accepts either a bare string (one author)
|
||||
/// or an array of strings (see [`RawAuthor`]); both load into this `Vec`.
|
||||
/// Mirrors Lean `Info.authors : List String`.
|
||||
pub authors: Vec<String>,
|
||||
}
|
||||
|
||||
/// One `[[parts]]` entry plus its loaded element descriptor.
|
||||
@@ -265,10 +276,36 @@ struct RawProject {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// The authoring-surface `[info]` (mirrors Lean `RawInfo` in
|
||||
/// `spec/Spec/Courseware/Model/Info.lean`): the raw form that exists for
|
||||
/// fill-in convenience, normalized into the canonical [`Info`] at the load
|
||||
/// boundary. Not the form the rest of the model traffics in.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawInfo {
|
||||
title: String,
|
||||
author: Option<String>,
|
||||
author: Option<RawAuthor>,
|
||||
}
|
||||
|
||||
/// On-disk `author`: either a single name (`author = "…"`) or a list
|
||||
/// (`author = ["…", "…"]`). Mirrors Lean `RawAuthor`: a fill-in convenience whose
|
||||
/// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`]
|
||||
/// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RawAuthor {
|
||||
One(String),
|
||||
Many(Vec<String>),
|
||||
}
|
||||
|
||||
impl RawAuthor {
|
||||
/// Flatten to the ordered author list (Lean `RawAuthor.normalize`): a single
|
||||
/// name becomes a one-element list; a list passes through verbatim.
|
||||
fn into_vec(self) -> Vec<String> {
|
||||
match self {
|
||||
RawAuthor::One(s) => vec![s],
|
||||
RawAuthor::Many(v) => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -361,7 +398,7 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
||||
let info = match raw.info {
|
||||
Some(i) => Info {
|
||||
title: i.title,
|
||||
author: i.author,
|
||||
authors: i.author.map(RawAuthor::into_vec).unwrap_or_default(),
|
||||
},
|
||||
None => {
|
||||
diags.push(
|
||||
|
||||
@@ -4,6 +4,8 @@ name = "target-configs"
|
||||
|
||||
[info]
|
||||
title = "测试课:导出目标的结构化 build 配置"
|
||||
# Array form: a lesson authored by several people.
|
||||
author = ["张老师", "李老师"]
|
||||
|
||||
# Full v2 config: explicit single-file artifact + an ordered typst-compile step.
|
||||
[targets.student]
|
||||
|
||||
@@ -27,7 +27,8 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
|
||||
assert_eq!(lesson.project.id, "fixture-valid");
|
||||
assert_eq!(lesson.project.name, "valid-2-part");
|
||||
assert_eq!(lesson.info.title, "测试课:两个部件");
|
||||
assert_eq!(lesson.info.author.as_deref(), Some("范式教育教研组"));
|
||||
// A single-string `author` loads as a one-element list.
|
||||
assert_eq!(lesson.info.authors, vec!["范式教育教研组".to_string()]);
|
||||
|
||||
// Parts preserve declared order: segment first, lemma second.
|
||||
assert_eq!(lesson.parts.len(), 2);
|
||||
@@ -149,6 +150,12 @@ fn structured_target_configs_parse_into_typed_builds() {
|
||||
"well-formed target configs must have no diagnostics, got: {diags:?}"
|
||||
);
|
||||
|
||||
// An array `author` loads as the ordered author list.
|
||||
assert_eq!(
|
||||
lesson.info.authors,
|
||||
vec!["张老师".to_string(), "李老师".to_string()]
|
||||
);
|
||||
|
||||
// Declared order is preserved.
|
||||
assert_eq!(lesson.target_names(), vec!["student", "archive", "teacher"]);
|
||||
|
||||
|
||||
@@ -42,8 +42,15 @@ pub fn build_augmented_manifest(lesson: &Lesson) -> String {
|
||||
"title".to_string(),
|
||||
toml::Value::String(lesson.info.title.clone()),
|
||||
);
|
||||
if let Some(author) = &lesson.info.author {
|
||||
info.insert("author".to_string(), toml::Value::String(author.clone()));
|
||||
if !lesson.info.authors.is_empty() {
|
||||
let authors = lesson
|
||||
.info
|
||||
.authors
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(toml::Value::String)
|
||||
.collect();
|
||||
info.insert("author".to_string(), toml::Value::Array(authors));
|
||||
}
|
||||
doc.insert("info".to_string(), toml::Value::Table(info));
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ fn file_tree_artifact_is_deferred() {
|
||||
},
|
||||
info: Info {
|
||||
title: "t".into(),
|
||||
author: None,
|
||||
authors: vec![],
|
||||
},
|
||||
parts: vec![],
|
||||
targets: vec![TargetConfig {
|
||||
@@ -238,7 +238,7 @@ fn shell_only_target_is_deferred() {
|
||||
},
|
||||
info: Info {
|
||||
title: "t".into(),
|
||||
author: None,
|
||||
authors: vec![],
|
||||
},
|
||||
parts: vec![],
|
||||
targets: vec![TargetConfig {
|
||||
|
||||
@@ -45,7 +45,7 @@ name = "…" # folder/display name
|
||||
|
||||
[info] # passed through to render targets verbatim
|
||||
title = "…"
|
||||
author = "…"
|
||||
author = "…" # one author; or a list: author = ["…", "…"]
|
||||
|
||||
[[parts]] # ORDER OF THIS ARRAY IS THE LESSON ORDER (ADR-0005)
|
||||
kind = "segment" # one of the known kinds (ADR-0006 / stdlib set)
|
||||
@@ -63,6 +63,18 @@ that imports elements in order is a *generated build artifact* derived from the
|
||||
manifest (see ADR-0007's note that the on-disk encoding of `Lesson` is separate
|
||||
from "it's a tree"), never the canonical order.
|
||||
|
||||
### `[info].author` is a list; the on-disk single-string form is sugar
|
||||
|
||||
A lesson can be authored by several people (a teaching group), so the canonical
|
||||
`author` is an **ordered list**, not a single value. For authoring convenience the
|
||||
on-disk form accepts **either** a bare string (`author = "…"`, one author) **or** an
|
||||
array (`author = ["…", "…"]`). That "string-or-array" union lives **only at the load
|
||||
boundary**: the loader normalizes a bare string to a one-element list, and
|
||||
everything downstream sees a list. Absent `author` ⇒ empty list. This raw-vs-
|
||||
canonical split is pinned in the Lean master as `RawInfo` (the authoring surface)
|
||||
vs `Info` (the canonical model whose `authors` is always a list) — see
|
||||
`spec/Spec/Courseware/Model/Info.lean`.
|
||||
|
||||
### An element folder is self-describing via `element.toml`
|
||||
|
||||
Each element folder carries an **`element.toml`** declaring its kind and its
|
||||
|
||||
@@ -2,10 +2,12 @@ import Spec.Courseware.Model.Primitives
|
||||
import Spec.Courseware.Model.RichContent
|
||||
import Spec.Courseware.Model.Element
|
||||
import Spec.Courseware.Model.Lesson
|
||||
import Spec.Courseware.Model.Info
|
||||
|
||||
/-!
|
||||
# Courseware.Model —— 工程文件的内容模型
|
||||
|
||||
留白基元(`Primitives`)、富内容锚点(`RichContent`)、原子单位(`Element`)、单节课
|
||||
(`Lesson`)。决策出处 ADR-0005 / 0006。
|
||||
(`Lesson`)、课时元信息(`Info`:canonical author 为列表 vs `RawInfo` 授权 surface)。
|
||||
决策出处 ADR-0005 / 0006 / 0008。
|
||||
-/
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/-!
|
||||
# Info —— 课时元信息:canonical 模型 vs 授权 surface
|
||||
|
||||
`[info]`(标题、作者)大多是 passthrough 元数据(ADR-0008),本不入契约。但**作者的
|
||||
基数**是一个真分歧点:一节课可由多人(教研组)署名,故 canonical 模型里 author 是一个
|
||||
**有序列表**,不是单值或可选单值。
|
||||
|
||||
另有一条值得钉的模式:on-disk 的授权形式是**语法糖**——单作者可写 `author = "…"`,
|
||||
多作者写 `author = ["…", "…"]`——但这个"字符串或数组"的二态**只活在加载边界**:`RawInfo`
|
||||
经归一化折叠成 canonical `Info`,其后不再出现。canonical 接收端始终是 `List String`,
|
||||
raw 形式不泄漏进模型其余部分。这正是 `Info`(canonical)与 `RawInfo`(授权 surface)
|
||||
两个结构存在的理由。
|
||||
-/
|
||||
|
||||
namespace Spec.Courseware
|
||||
|
||||
/-- 作者的**授权 surface 形式**(`PINNED` 仅授权便利, ADR-0008)。on-disk 单作者可写裸
|
||||
字符串、多作者写数组——填写便利,非语义分歧。此 union **只活在加载边界**,经
|
||||
`RawAuthor.normalize` 折叠后不再出现。 -/
|
||||
inductive RawAuthor where
|
||||
/-- 单作者裸字符串 `author = "…"`。 -/
|
||||
| one (name : String)
|
||||
/-- 多作者数组 `author = ["…", "…"]`。 -/
|
||||
| many (names : List String)
|
||||
|
||||
/-- raw 作者归一化为**有序作者列表**(`PINNED`, ADR-0008)。单作者 ⇒ 单元素列表;数组
|
||||
⇒ 原样。这条钉死"canonical 接收端始终是 `List String`"。 -/
|
||||
def RawAuthor.normalize : RawAuthor → List String
|
||||
| .one n => [n]
|
||||
| .many ns => ns
|
||||
|
||||
/-- 课时元信息的 **canonical 模型**(`PINNED` author 为列表, ADR-0008)。`authors` 是
|
||||
**有序列表**:多人署名第一类,空列表 = 未署名。`title` 等其余字段是 passthrough 元数据,
|
||||
不在此承诺更多。这是系统其余部分唯一所见的形态——author 在此**已**是列表,不再是
|
||||
"字符串或数组"。 -/
|
||||
structure Info where
|
||||
/-- 标题(passthrough 元数据)。 -/
|
||||
title : String
|
||||
/-- 作者**有序列表**(空 = 未署名)。canonical 始终是列表。 -/
|
||||
authors : List String
|
||||
|
||||
/-- 授权 surface 的 `[info]`(`PINNED` 仅授权便利, ADR-0008)。`author` 用 `RawAuthor`
|
||||
(字符串或数组),`author` 缺省即未署名。此结构刻画"为便于填写而存在的 raw 形态",
|
||||
**不**是模型其余部分流通的形式——它经 `RawInfo.toInfo` 归一化为 canonical `Info`。 -/
|
||||
structure RawInfo where
|
||||
/-- 标题。 -/
|
||||
title : String
|
||||
/-- 作者 raw 形式(可选;缺省即未署名)。 -/
|
||||
author : Option RawAuthor
|
||||
|
||||
/-- raw `[info]` 归一化为 canonical `Info`(`PINNED` 加载边界归一化, ADR-0008)。缺省
|
||||
author ⇒ 空列表,否则按 `RawAuthor.normalize`。raw 的"字符串或数组"二态在此被消解,
|
||||
**不**泄漏进 `Info`——canonical 接收端恒为 `List String`。 -/
|
||||
def RawInfo.toInfo (r : RawInfo) : Info :=
|
||||
{ title := r.title
|
||||
authors := (r.author.map RawAuthor.normalize).getD [] }
|
||||
|
||||
end Spec.Courseware
|
||||
Reference in New Issue
Block a user