Skip to content

Add COPEN transition plans, with COPEN to CTFYS as the first - #68

Merged
cohm merged 1 commit into
mainfrom
copen-transition-plans
Aug 23, 2026
Merged

Add COPEN transition plans, with COPEN to CTFYS as the first#68
cohm merged 1 commit into
mainfrom
copen-transition-plans

Conversation

@cohm

@cohm cohm commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Starts #66. First slice: the plumbing, plus COPEN → CTFYS as the worked example.

A COPEN student takes one common year and then transfers into a five-year programme, so what they actually study is published nowhere — COPEN's plan stops after year 1, and the target's plan assumes its own year 1. Picking COPEN and then a continuation programme now composes the real thing.

The plan records the difference, not a course list

src/data/transitions.json, one entry per (from, to) pair:

{ "from": "COPEN", "to": "CTFYS", "sourceYears": [1],
  "credited": [{ "code": "SF1626", "replaces": ["SF1674"] },
               { "code": "SF1624", "replaces": ["SF1672"] },
               { "code": "SA1007" }],
  "exempt": [{ "code": "SF1544", "creditedBy": "SF1546" }],
  "added":  [{ "code": "SF1920", "year": 2, "periodCredits": { "P3": 6 },
               "substitutesFor": "SF1922", "fromProgram": "CELTE",
               "cosmeticsGroup": "Matematik" }],
  "verified": false }

(Abridged — there are nine credited entries, six of which name a replaces.) Four kinds of statement: credited (a source course counting toward the target degree, optionally naming the target course it replaces), exempt (a target course credited away), added (a course from neither published plan), and moved (a target course shifted to a later year — unused today, kept because it's the natural shape for "the target's own course, later").

Declarative on purpose: a hand-written combined course list would go stale the moment either programme is re-extracted, and silently — nothing would report that the composition no longer matched. Recording only the difference makes composeTransition a pure function of both programmes' current data. Year numbering needs no adjustment: source year 1 + target years 2–3 already reads 1/2/3.

SF1920 from CELTE, not SF1922 from CTFYS

A COPEN student has no probability course, and CTFYS teaches SF1922 in its year 1, which the transfer student was never present for. #66 suggested taking SF1922 in P4 of year 2 with CTFYS's year-1 students. Taking SF1920 in P3 with CELTE's second year is better on both counts — no waiting until P4, and the credits land in the period the exemption emptied:

composed year 2 P1 P2 P3 P4
SF1922 moved to y2 P4 15 14 10 21
SF1920 from CELTE in P3 15 14 16 15

Both total 60 hp — which is exactly why the load check has to be per period. The residual ±1 is structural: SF1544 carried 1 hp in P2 and 5 in P3, while SF1920 is 6 hp all in P3.

An added course needs its own data, since CELTE isn't in programs.json. It's embedded in the plan in the same raw shape a data file uses and parsed with parseCourseEntries — the loader's own parser, split out for this so the period/credit normalisation isn't reimplemented. SF1920's values were read from the live sources the extractor uses: 6 hp and P3 from CELTE's year-2 plan, TEN1 and the SF1625 prerequisite from its course page. That prerequisite resolves inside the composed plan, since SF1625 is one of the nine COPEN courses — and the arrow is drawn.

cosmeticsGroup turned out to be necessary rather than decorative: without it the course falls to the default colour, which beside the light-tone palette reads as a bug rather than as "this came from elsewhere". SF1920 declares Matematik and now renders identically to CTFYS's other maths courses (verified: same computed fill as SF1681 and SF1683).

Prerequisite arrows follow the course actually taken

A target course in years 2–3 states its prerequisites in the target's own terms — CTFYS's SF1683 and SI1146 both require SF1674. A transfer student never took SF1674; they took SF1626, the same subject. Drawn to the letter, the arrow starts from a course that isn't in their plan and simply vanishes, leaving those courses looking as though they had no prerequisites at all.

So credited is a list of objects that can say what each course replaces, and redirectPrerequisites rewrites every reference through those equivalences — a general rule over the plan's data, not a list of special cases. Exactly five CTFYS year-1 courses are referenced from years 2–3, and all five resolve:

referenced target course becomes arrows affected
SF1674 Flervariabelanalys SF1626 → SF1683, → SI1146
SF1672 Linjär algebra SF1624 → SF1681
DD1331 Grundläggande programmering DD1310 → DD1327
SG1112 Mekanik I SG1133 → SE1055, → SG1113
SK1104 Klassisk fysik SK1115 → SH1014

Three fields feed the same map, so an equivalence is stated once: credited[].replaces, exempt[].creditedBy (SF1546 → SF1544 — which is why SF1546 carries no replaces; the validator caught that redundancy), and added[].substitutesFor.

A reference that survives the rewrite but names a course outside the composed plan is reported: that means an equivalence is missing, and the symptom would otherwise be a silently absent arrow. validate-data checks the same invariant statically, so a missing equivalence fails in CI rather than only in the browser.

Cosmetics have to be merged

The two files share no course codes, so rendering from the target's cosmetics alone drew all nine COPEN courses in the default colour. mergeCosmetics merges by group name — "Matematik" from both becomes one legend row — with the target's colour winning on conflict:

group CTFYS COPEN composed
Matematik blue blue blue
Fysik green green green
Ingenjörsämnen brick turquoise brick (target wins)
Övrigt yellow yellow
Programmering brick turquoise (first free family)

A source-only group takes the first unused family rather than its own, because COPEN's Programmering is brick, which CTFYS already spends on Ingenjörsämnen. COPEN+CTFYS lands on exactly five families — the documented hard cap. An overflow is reported and those courses fall back to the default colour.

Full-time load, checked per period

year 1  COPEN      15 / 15 / 15 / 15
year 2  composed   15 / 14 / 16 / 15
year 3  CTFYS      15 / 15 / 15 / 15

A swap can balance across a year while leaving individual periods lopsided, and the year total hides it entirely — so the check is per period, the same signal validate-data applies to the programme files. The ±1 is shown in the UI rather than corrected: where the plan puts a course is the program director's call.

The chart is titled for both programmes

Civilingenjörsutbildning Öppen ingång → Teknisk fysik (COPEN → CTFYS)

The target's name is shortened, because the qualification is identical on both sides and pure noise the second time. shortProgramName strips a fixed set of openers rather than the longest common word prefix — the latter looks more general but is wrong on the English names: "Degree Program in Engineering - Open Entrance" and "Degree Program in Engineering Physics" share "Degree Program in Engineering", which would reduce CTFYS to "Physics". An unrecognised name falls through unchanged.

Verified in both languages, and that a plain programme view is untouched:

view title
COPEN alone Civilingenjörsutbildning Öppen ingång (COPEN)
COPEN + CTFYS (sv) Civilingenjörsutbildning Öppen ingång → Teknisk fysik (COPEN → CTFYS)
COPEN + CTFYS (en) Degree Program in Engineering - Open Entrance → Engineering Physics (COPEN → CTFYS)
CTFYS alone Civilingenjörsutbildning i Teknisk fysik (CTFYS)

The SVG <title> used by screen readers picks it up too, with the right year range. The composed code also reaches the export audit stamp (COPEN → CTFYS · build … · date) — wanted; export filenames are fixed strings and unaffected.

Validation

validate-data cross-checks each plan against both programmes, in both directions: credited must match what the source actually teaches in those years — a course added to COPEN that nobody added to the plan would otherwise be dropped silently — and every exempt/moved code must exist in the target with the year the plan claims. verified: false warns, exactly as in programs.json.

Verification

Headless Chrome, ?program=COPEN&continuation=CTFYS:

check result
years rendered 3 (was 1)
SF1544 (exempt) absent
SF1922 (CTFYS year 1) absent — year 1 is COPEN's
SF1920 (added) present at year 2, P3, in the Matematik colour
SF1625 → SF1920 prerequisite arrow drawn
COPEN year-1 courses 9/9 present, all coloured
CTFYS year-1-only courses none leaked through
notice line names what was credited, dropped and added
load line shows 15/14/16/15
every arrow from the five year-1 courses redirected to its COPEN equivalent
arrows still starting from a CTFYS year-1 course none
COPEN alone unchanged
console / page errors none

lint, tsc --noEmit, validate-data --cohorts (0 errors) and the build all clean.

Not done

  • Only COPEN → CTFYS, marked verified: false. The other targets (CINEK, CTMAT, …) need their plans in whatever format they arrive; the schema above is what to map them onto, and adding one is a data-only change.
  • moved is implemented and validated but unused — CTFYS needs only exempt + added. It stays because "take the target's own course a year later" is a shape other programmes may well need.

Starts #66. A COPEN student takes one common year and then transfers into a
five-year programme, so what they actually study is published nowhere: COPEN's
plan stops after year 1, and the target's plan assumes its own year 1. Picking
COPEN and then a continuation programme now composes the real thing - COPEN year
1 followed by years 2-3 of the target, with the transition plan applied.

THE PLAN RECORDS THE DIFFERENCE, NOT A COURSE LIST
src/data/transitions.json holds one entry per (from, to) pair with three shapes
of difference:

  exempt  a target course the student does not take because a source course
          credits it - CTFYS drops SF1544, credited by COPEN's SF1546
  added   a course from neither published plan - SF1920 in P3 of year 2, taken
          with CELTE's second year
  moved   a target course shifted to a later year, keeping its periods; unused
          today but the natural shape for "the target's own course, later"

A hand-written combined course list would go stale the moment either programme
is re-extracted, and silently - nothing would report that the composition no
longer matched. Recording only the difference makes composeTransition a pure
function of both programmes' current data.

Year numbering needs no adjustment: the source contributes year 1 and the target
years 2-3, so the composed years already read 1/2/3.

WHY SF1920 AND NOT SF1922
A COPEN student has no probability course, and CTFYS teaches SF1922 in its year
1, which the transfer student was never present for. Issue #66 suggested taking
SF1922 in P4 of year 2 with CTFYS's year-1 students. Taking SF1920 in P3 with
CELTE's second year is better on both counts - the student does not wait until
P4, and the credits land in the period the exemption emptied:

  SF1922 moved to y2 P4     15 / 14 / 10 / 21
  SF1920 from CELTE in P3   15 / 14 / 16 / 15

Both total 60 hp, which is exactly why the load check has to be per period. The
residual +-1 is structural: SF1544 carried 1 hp in P2 and 5 in P3, while SF1920
is 6 hp all in P3.

An added course needs its own data, since CELTE is not a programme this app
models. It is embedded in the plan in the same raw shape a data file uses and
parsed with parseCourseEntries - the loader's own parser, split out for this
purpose so the period/credit normalisation is not reimplemented. SF1920's values
come from the live sources the extractor uses: 6 hp and P3 from CELTE's year-2
study plan, TEN1 and the SF1625 prerequisite from its course page. That
prerequisite resolves inside the composed plan, because SF1625 is one of the
nine COPEN courses, and the arrow is drawn.

cosmeticsGroup is needed in practice: without it an added course falls to the
default colour, which beside the light-tone palette reads as a bug rather than as
"this came from elsewhere". SF1920 declares Matematik and renders identically to
CTFYS's other maths courses.

PREREQUISITE ARROWS FOLLOW THE COURSE ACTUALLY TAKEN
A target course in years 2-3 states its prerequisites in the target's own terms:
CTFYS's SF1683 and SI1146 both require SF1674. A transfer student never took
SF1674 - they took SF1626, the same subject - so an arrow drawn to the letter
starts from a course that is not in their plan and simply vanishes, leaving those
courses looking as though they had no prerequisites at all.

credited[] is therefore a list of objects, each able to say what it replaces, and
redirectPrerequisites rewrites every reference through those equivalences. It is a
general rule over the plan's data rather than a list of special cases. Exactly
five CTFYS year-1 courses are referenced from years 2-3, and all five resolve:

  SF1674 -> SF1626    arrows into SF1683, SI1146
  SF1672 -> SF1624    arrow into SF1681
  DD1331 -> DD1310    arrow into DD1327
  SG1112 -> SG1133    arrows into SE1055, SG1113
  SK1104 -> SK1115    arrow into SH1014

Three fields feed the same map, so an equivalence is stated once:
credited[].replaces, exempt[].creditedBy (SF1546 -> SF1544, which is why SF1546
carries no 'replaces'), and added[].substitutesFor. A reference that survives the
rewrite but names a course outside the composed plan is reported - that means an
equivalence is missing, and the symptom would otherwise be a silently absent
arrow. validate-data checks the same invariant statically, so a missing
equivalence fails in CI rather than only in the browser.

COSMETICS HAVE TO BE MERGED
The two files share no course codes, so rendering a composed chart from the
target's cosmetics alone drew all nine COPEN courses in the default colour.
mergeCosmetics merges by group name, so Matematik from both programmes becomes
one legend row, with the target's colour winning on conflict: CTFYS has
Ingenjörsämnen brick where COPEN has it turquoise. A group only the source has
takes the first unused family rather than its own, because COPEN's Programmering
is brick, which CTFYS already spends on Ingenjörsämnen. COPEN+CTFYS lands on
exactly five families, which is the documented hard cap; an overflow is reported
and those courses fall back to the default colour.

FULL-TIME LOAD IS CHECKED PER PERIOD
The same signal validate-data applies to the programme files, applied to the
composition, because a swap can balance across a year while leaving individual
periods lopsided and the year total hides it. Composed year 2 is 15/14/16/15;
the ±1 is reported in the UI rather than corrected, since where the plan puts a
course is the program director's call.

THE CHART IS TITLED FOR BOTH PROGRAMMES
"Civilingenjörsutbildning Öppen ingång -> Teknisk fysik (COPEN -> CTFYS)". The
target's name is shortened because the qualification is identical on both sides
and pure noise the second time. shortProgramName strips a fixed set of openers
rather than the longest common word prefix, which looks more general but is wrong
on the English names: "Degree Program in Engineering - Open Entrance" and "Degree
Program in Engineering Physics" share "Degree Program in Engineering", which would
reduce CTFYS to "Physics". An unrecognised name falls through unchanged. The
composed code also reaches the export audit stamp, which is wanted; export
filenames are fixed strings and unaffected.

VALIDATION
validate-data cross-checks each plan against both programmes in both directions.
`credited` must match what the source actually teaches in those years - a course
added to COPEN that nobody added to the plan would otherwise be dropped silently
- and every exempt/moved code must exist in the target with the year the plan
claims. verified: false warns, exactly as in programs.json.

Verified in headless Chrome, COPEN + CTFYS: three years render; SF1544 and
SF1922 both absent; SF1920 drawn at year 2 P3 in the Matematik colour, with the
SF1625 prerequisite arrow; all nine COPEN courses present and coloured; every
CTFYS year-1-only course absent; the notice line naming what was credited,
dropped and added; and the load line showing 15/14/16/15. No console or page
errors. COPEN alone is unchanged.

Arrows verified against CTFYS alone: every arrow that started from one of the five
year-1 courses now starts from its COPEN equivalent, and no arrow in the composed
view starts from a CTFYS year-1 course. lint, tsc --noEmit, validate-data and the
build are clean.

WHAT IS NOT DONE
Only COPEN -> CTFYS exists, and it is marked verified: false. The other targets
(CINEK, CTMAT, ...) need their plans from @sellberg in whatever format they
arrive; the schema above is the target to map them onto, and adding one is a
data-only change.
@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
program-visualization Ready Ready Preview Aug 22, 2026 4:08pm

@cohm
cohm merged commit d8a4d9e into main Aug 23, 2026
5 checks passed
@cohm
cohm deleted the copen-transition-plans branch August 23, 2026 19:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant