diff --git a/.github/workflows/schedule-validate.yml b/.github/workflows/schedule-validate.yml new file mode 100644 index 0000000..260bff8 --- /dev/null +++ b/.github/workflows/schedule-validate.yml @@ -0,0 +1,58 @@ +# Gate schedule DATA edits before they reach the deploy branch (#54). +# +# This is the validation CI the existing jekyll.yml (build + deploy on push to gh-pages) +# does NOT provide: a Jekyll build stays green even when a schedule is wrong, because +# Liquid renders an undefined macro (e.g. `{{ mlect }}`) as the empty string. This workflow +# runs tools/validate_schedule.py (Tier 0 schema + Tier 1 referential) so a bad edit fails a +# check with a clear, annotated error instead of silently shipping a broken page. +# +# Make this a REQUIRED status check in branch protection on gh-pages to actually gate merges. +name: Validate schedule data + +on: + pull_request: + paths: + - '_data/schedule/**' + - 'ICI3D.github.io' # submodule pointer = the shared people data + - 'schemas/**' + - 'tools/validate_schedule.py' + - '.github/workflows/schedule-validate.yml' + push: + branches: ['gh-pages'] + paths: + - '_data/schedule/**' + - 'ICI3D.github.io' + - 'schemas/**' + - 'tools/validate_schedule.py' + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout (with the ICI3D.github.io submodule for _data/team) + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install validator dependencies + run: python -m pip install --quiet pyyaml jsonschema + + - name: Validate every cohort schedule + run: | + shopt -s nullglob + files=(_data/schedule/*/*.yml) + if [ ${#files[@]} -eq 0 ]; then + echo "No cohort schedule files under _data/schedule// — nothing to validate." + exit 0 + fi + echo "Validating: ${files[*]}" + python3 tools/validate_schedule.py "${files[@]}" --github diff --git a/_data/schedule/mmed/2025.yml b/_data/schedule/mmed/2025.yml new file mode 100644 index 0000000..5046d90 --- /dev/null +++ b/_data/schedule/mmed/2025.yml @@ -0,0 +1,1651 @@ +clinic: mmed +year: 2025 +status: published +title: MMED 2025 +timezone: Africa/Johannesburg +display_timezones: +- Africa/Johannesburg +tracks: +- main +- Section 1 +- Section 2 +locations: + main-hall: Main Hall + comp-lab: Comp. Lab + group-breakouts: Group Breakouts + lobby: Lobby + sections: Section 1 / Section 2 +meal_defaults: + breakfast: + start: '07:45' + end: '08:15' + dinner: + start: '18:00' + end: '18:30' +weeks: +- title: 'Week 0: MMED Foundations' + collapsible: true + open: false + days: + - date: '2025-06-09' + label: Day 1 (Monday, 9 June) + sessions: + - start: '09:00' + end: '09:20' + kind: organizing + track: main + title: Introductions, Overview + instructors: + - everyone + location: main-hall + links: + - text: Introductions, Overview + url: https://docs.google.com/presentation/d/1g7F4kTMvPGq1NobmcAtLj_amwjV76MrFGRrJNURISBM + - start: '09:20' + end: '10:30' + kind: discussion + track: main + title: Public health, epidemiology, and infectious disease modelling + instructors: + - pearson + location: main-hall + links: + - text: Public health, epidemiology, and infectious disease modelling + url: https://docs.google.com/presentation/d/1Cxt6ZtRCalcu0iV0pX9fwDaW7DeNYi9Zgg58vUD5EfA + - start: '10:30' + end: '11:00' + kind: coffee + track: main + - start: '11:00' + end: '12:00' + kind: lecture + track: main + title: Introduction to infectious disease dynamics, Part I + instructors: + - mthombothi + location: main-hall + links: + - text: Introduction to infectious disease dynamics, Part I + url: https://drive.google.com/file/d/1nbtUAtmsc1SEbKq3-3YAz83_T2q1lYM0 + - start: '12:00' + end: '13:00' + kind: lecture + track: main + title: Simple Models + instructors: + - pearson + location: main-hall + links: + - text: Simple Models + url: https://docs.google.com/presentation/d/1tFHnRbpf-KBbA_k301pA3WAlS3FbhKlardRRb6jbis4 + - start: '13:00' + end: '14:00' + kind: meal + meal: lunch + track: main + - start: '14:00' + end: '15:30' + kind: computer-session + track: main + title: 'Tutorial 1: Introduction to R and Epidemic curves' + instructors: + - Tutors + location: comp-lab + links: + - text: 'Tutorial 1: Introduction to R and Epidemic curves' + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_RTutorial_1.R + - start: '15:30' + end: '16:00' + kind: break + track: main + - start: '16:00' + end: '17:00' + kind: computer-session + track: main + title: Tutorial 1 cont. + instructors: + - Tutors + location: comp-lab + links: + - text: Tutorial 1 cont. + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_RTutorial_1.R + - start: '17:00' + end: '17:45' + kind: discussion + track: main + title: How to read a scientific paper + instructors: + - pearson + location: main-hall + links: + - text: How to read a scientific paper + url: ../MedPH/How_to_read_exRabies.pdf + notes: + - '[Reference Paper](https://doi.org/10.1371/journal.pbio.1000053)' + - start: '17:45' + end: '18:00' + kind: organizing + track: main + title: Introduction to projects + instructors: + - pearson + location: main-hall + links: + - text: Introduction to projects + url: ../tutorials/pilotproject + notes: + - '[Project Groups](https://docs.google.com/spreadsheets/d/1gLfek_HAcFw-B1raWN6ZPZH_MHC4gtE88sjIf-jaIkc)' + links: + - text: End of Day Questions + url: https://forms.gle/wFNTAMsQU7JEQWrU9 + notes: + - text: Faculty meeting and dinner in E313 from 16h30; be sure to save food for late arrivals once + known + shadow: true + - date: '2025-06-10' + label: Day 2 + sessions: + - start: '08:30' + end: '09:00' + kind: activity + track: main + title: Reading time for a scientific paper + links: + - text: Reading time for a scientific paper + url: https://doi.org/10.1371/journal.pbio.1000053 + - start: '09:00' + end: '10:30' + kind: lecture + track: main + title: Introduction to infectious disease dynamics, Part II + instructors: + - mthombothi + location: main-hall + links: + - text: Introduction to infectious disease dynamics, Part II + url: https://drive.google.com/file/d/1SzQnv9haPgZ_o10GbGDT4nmoOvBeVU_T + notes: + - '["The" R0 paper](https://link.springer.com/article/10.1007/bf00178324) (might need [unpaywall](https://unpaywall.org/products/extension) + or similar tools to access)' + - start: '10:30' + end: '11:00' + kind: coffee + track: main + - start: '11:00' + end: '12:00' + kind: lecture + track: main + title: Dynamics of directly transmitted pathogens + instructors: + - pearson + location: main-hall + links: + - text: Dynamics of directly transmitted pathogens + url: https://docs.google.com/presentation/d/1zFQVS4gHNyfFPXKuz8BIvweMzWQNf6c9 + - start: '12:00' + end: '13:00' + kind: computer-session + track: main + title: 'Tutorial 2: More on Vectors, Data Frames, and Functions, and SEIR' + instructors: + - Tutors + location: comp-lab + links: + - text: 'Tutorial 2: More on Vectors, Data Frames, and Functions' + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_RTutorial_2.R + - text: SEIR + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/seir.R + - start: '13:00' + end: '14:00' + kind: meal + meal: lunch + track: main + - start: '14:00' + end: '15:30' + kind: computer-session + track: main + title: Tutorial 2 & benchmark questions, and SEIR & Benchmark questions + instructors: + - Tutors + location: comp-lab + links: + - text: Tutorial 2 & benchmark questions + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_RTutorial_2.R + - text: SEIR & Benchmark questions + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/seir.R + - start: '15:30' + end: '16:00' + kind: break + track: main + - start: '16:00' + end: '18:00' + kind: group-work + track: main + title: Project development + location: group-breakouts + links: + - text: Project development + url: ../tutorials/pilotproject + links: + - text: End of Day Questions + url: https://forms.gle/cWeCsKXKX6GSWSnk8 + - date: '2025-06-11' + label: Day 3 + sessions: + - start: '08:30' + end: '09:30' + kind: group-work + track: main + title: Project development + location: group-breakouts + links: + - text: Project development + url: ../tutorials/pilotproject + notes: + - 'from 08h30, each group will check in with Carl (odd, in 105) or Zinhle (even, lobby): 08h30 1 + and 2, 08h50 3 and 4, 09h10 5 and 6' + - '[Project Groups](https://docs.google.com/spreadsheets/d/1gLfek_HAcFw-B1raWN6ZPZH_MHC4gtE88sjIf-jaIkc)' + - start: '09:30' + end: '10:30' + kind: discussion + track: main + title: How did you read a scientific paper? + instructors: + - pearson + location: comp-lab + notes: + - '[Reference Paper](https://doi.org/10.1371/journal.pbio.1000053)' + - start: '10:30' + end: '11:00' + kind: coffee + track: main + - start: '11:00' + end: '11:45' + kind: lecture + track: main + title: 'Study Design and Analysis in Epidemiology: Where does modeling fit?' + instructors: + - pearson + location: main-hall + links: + - text: 'Study Design and Analysis in Epidemiology: Where does modeling fit?' + url: https://docs.google.com/presentation/d/15-i4vcP9oIOm3eKfqQs4O2Y7BSce1PI8 + - start: '11:45' + end: '13:00' + kind: computer-session + track: main + title: 'Lab 3: Study Design in Epidemiology' + instructors: + - Tutors + location: comp-lab + links: + - text: 'Lab 3: Study Design in Epidemiology' + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_Lab_EpiStudyDesign.R + - start: '13:00' + end: '14:00' + kind: meal + meal: lunch + track: main + - start: '14:00' + end: '15:30' + kind: computer-session + track: main + title: Binomial Distribution Tutorial + instructors: + - Tutors + location: comp-lab + links: + - text: Binomial Distribution Tutorial + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/binomialDistribution.R + - start: '15:30' + end: '16:00' + kind: break + track: main + - start: '16:00' + end: '18:00' + kind: group-work + track: main + title: Project development + location: group-breakouts + links: + - text: Project development + url: ../tutorials/pilotproject + links: + - text: End of Day Questions + url: https://forms.gle/zrD8asaQy6UVP4F68 + - date: '2025-06-12' + label: Day 4 + sessions: + - start: '08:30' + end: '09:15' + kind: lecture + track: main + title: 'Study Design and Analysis, part II: RCT’s' + instructors: + - pearson + location: main-hall + links: + - text: 'Study Design and Analysis, part II: RCT’s' + url: https://docs.google.com/presentation/d/14Vv6XiQjOVgNjGU0vyLeYUFj-9Azf50y + notes: + - 'Side reading: [CDC on the Tuskegee Experiment](https://www.cdc.gov/tuskegee/timeline.htm), [Declaration + of Helsinki](https://www.wma.net/policies-post/wma-declaration-of-helsinki-ethical-principles-for-medical-research-involving-human-subjects/), + [Belmont Report](https://www.hhs.gov/ohrp/regulations-and-policy/belmont-report/index.html)' + - start: '09:15' + end: '10:30' + kind: computer-session + track: main + title: 'Lab 4: Study Design for Clinical Trials' + instructors: + - Tutors + location: comp-lab + links: + - text: 'Lab 4: Study Design for Clinical Trials' + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_Lab_RCT.R + - start: '10:30' + end: '11:00' + kind: coffee + track: main + - start: '11:00' + end: '12:00' + kind: lecture + track: main + title: Transmission in Finite Populations + instructors: + - mthombothi + location: main-hall + links: + - text: Transmission in Finite Populations + url: https://drive.google.com/file/d/1mCqNCQ4Ak78uW6VUYyRRnN0p3Iv3zfEn + - start: '12:00' + end: '13:00' + kind: group-work + track: main + title: Project development, with progress checkin + location: comp-lab + links: + - text: Project development + url: ../tutorials/pilotproject + notes: + - All checkins in the lab. Odd groups check in with Carl, even with Zinhle. Be prepared to show + your draft report, slides, and poster. + - '[Project Groups](https://docs.google.com/spreadsheets/d/1gLfek_HAcFw-B1raWN6ZPZH_MHC4gtE88sjIf-jaIkc)' + - start: '13:00' + end: '14:00' + kind: activity + track: main + title: Lunch + - start: '14:00' + end: '15:30' + kind: group-work + track: main + title: Project development + links: + - text: Project development + url: ../tutorials/pilotproject + - start: '15:30' + end: '16:00' + kind: break + track: main + - start: '16:00' + end: '18:00' + kind: computer-session + track: main + title: 'If incomplete: review questions from Lab 4: Study Design for Clinical Trials , Tutorial + 3: Probability Distributions and Control Structures, and review questions . Otherwise Project + development' + instructors: + - Tutors + location: comp-lab + links: + - text: 'Lab 4: Study Design for Clinical Trials' + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_Lab_RCT.R + - text: 'Tutorial 3: Probability Distributions and Control Structures, and review questions' + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_RTutorial_3.R + - text: Project development + url: ../tutorials/pilotproject + links: + - text: Final Fundamentals Week Quiz (includes Qs from Friday) + url: https://forms.gle/Lrc1GcGbYcqDZZhZA + - date: '2025-06-13' + label: Day 5 + sessions: + - start: '08:30' + end: '09:30' + kind: lecture + track: main + title: Dynamics of vector-borne pathogens + instructors: + - mthombothi + location: main-hall + links: + - text: Dynamics of vector-borne pathogens + url: https://drive.google.com/file/d/1D5kCsgvKuA6VRTl-5kUZs6nJ3mWmcnSe + - start: '09:30' + end: '10:30' + kind: group-work + track: main + title: Final Group Work + location: group-breakouts + notes: + - submit slides to [Zinhle](mailto:zinhle@aims.ca.za) by 10h30 + - start: '10:30' + end: '11:00' + kind: coffee + track: main + - start: '11:00' + end: '13:00' + kind: group-work + track: main + title: Practice presentations and feedback + instructors: + - all + location: main-hall + notes: + - '**Aim** for: 6 minutes, 3-4 minutes for questions, 3-4 minutes for feedback; max 15 minutes' + - 'Groups: 11h00-11h15 4, 11h15-11h30 1, 11h30-11h45 3, 11h45-12h00 2, 12h00-12h15 5 (with 45 minute + buffer for overruns)' + - start: '13:00' + end: '14:00' + kind: meal + meal: lunch + track: main + - start: '14:00' + end: '15:00' + kind: group-work + track: main + title: Revisions + location: group-breakouts + - start: '15:00' + end: '15:30' + kind: discussion + track: main + title: Review / Preview + location: main-hall + - start: '15:30' + end: '16:00' + kind: break + track: main + - start: '16:00' + end: '18:00' + kind: group-work + track: main + title: 'Optional: re-present + re-feedback. Optional: poster feedback.' + location: main-hall +- title: 'Week 1: Meaningful Modeling of Epidemiological Data (MMED)' + collapsible: true + open: false + days: + - date: '2025-06-15' + label: Day 0 (Sunday, 15 June) + sessions: [] + notes: + - On arrival {{ org }} mentors and tutors will assist with direction to accommodations + - text: Faculty meeting and dinner in E313 from 16h30; be sure to save food for late arrivals once + known + shadow: true + - date: '2025-06-16' + label: Day 1 + sessions: + - start: '07:30' + end: '08:20' + kind: organizing + track: main + title: Registration + instructors: + - bruce + location: lobby + - start: '08:30' + end: '09:00' + kind: activity + track: main + title: Welcome and Motivation for Workshop + instructors: + - kassanjee + location: main-hall + links: + - text: Welcome and Motivation for Workshop + url: https://drive.google.com/file/d/15P91x6rYAw5JW0Yu74YwgDVAtXwNnAef + - start: '09:00' + end: '09:45' + kind: discussion + track: main + title: Public health, epidemiology, and models + instructors: + - pearson + location: main-hall + links: + - text: Public health, epidemiology, and models + url: https://docs.google.com/presentation/d/1LbeHeiB-JlZk1tQq7IjMwnuwXQF7SYZE + notes: + - text: 'Notes: Mutono' + shadow: true + - start: '09:45' + end: '10:00' + kind: activity + track: main + title: MMED roadmap and program overview + instructors: + - kassanjee + location: main-hall + links: + - text: MMED roadmap and program overview + url: ../roadmap + - start: '10:00' + end: '10:30' + kind: coffee + track: main + - start: '10:30' + end: '11:30' + kind: lecture + track: main + title: Introduction to dynamic modelling of infectious disease I + instructors: + - kassanjee + location: main-hall + links: + - text: Introduction to dynamic modelling of infectious disease I + url: https://drive.google.com/file/d/1z9fvgjnNkFOY0Q3fvis4qvk_71xJqBw8 + notes: + - text: 'Notes: Carl' + shadow: true + - start: '11:30' + end: '12:30' + kind: lecture + track: main + title: Introduction to infectious disease data + instructors: + - blumberg + location: main-hall + links: + - text: Introduction to infectious disease data + url: https://drive.google.com/file/d/1F3QSyDc0SnYBkkBfFdVHk6Cv2qKd_MJE + notes: + - text: 'Notes: Carl' + shadow: true + - start: '12:30' + end: '13:00' + kind: activity + track: main + title: faculty meeting + shadow: true + - start: '12:30' + end: '14:00' + kind: meal + meal: lunch + track: main + - start: '13:15' + end: '13:45' + kind: activity + track: main + title: (technical assistance for participants + notes: + - text: Led by Carl + shadow: true + - start: '14:00' + end: '15:00' + kind: lecture + track: main + title: Foundations of dynamical modelling + instructors: + - dushoff + location: main-hall + links: + - text: Foundations of dynamical modelling + url: https://drive.google.com/file/d/1_sa6YOal0UqElX3e-uQ6HZvYRfY_RIV8 + notes: + - text: 'Notes: Seth the Motivator' + shadow: true + - start: '15:00' + end: '15:30' + kind: computer-session + track: main + title: Dynamical fever exercise + instructors: + - nyamai + location: comp-lab + notes: + - Launch from R prompt with `ICI3D::dynamicalFever()` + - text: assisted by Seth, Carl, Jeremy, Martha + shadow: true + - text: Stanley should relax! + shadow: true + - start: '15:30' + end: '16:00' + kind: tea + track: main + title: (poster setup, group 1) + instructors: + - nyamai + - Mentors + location: main-hall + links: + - text: poster setup, group 1 + url: https://drive.google.com/drive/folders/19DlY2bPRbzAsHIux5nSeBibbCSXlKBrU + - start: '16:00' + end: '17:00' + kind: activity + track: main + title: Dynamical fever exercise, cont. + location: comp-lab + notes: + - '[Dynamical fever summary](https://docs.google.com/presentation/d/18POIDzoVrtrXAlZAYbJ9PiajPpssGu7i) + ({% include instructors people="nyamai" %}, {{ lab }})' + - start: '17:00' + end: '18:00' + kind: activity + track: main + title: Poster session 1 + location: main-hall + links: + - text: Poster session 1 + url: https://drive.google.com/drive/folders/19DlY2bPRbzAsHIux5nSeBibbCSXlKBrU + - start: '19:00' + end: '20:30' + kind: activity + track: main + title: Ice-breaker/Card games + instructors: + - bruce + location: main-hall + links: + - text: End of Day Questions + url: https://forms.gle/zkufuNG8PhfB2YEg9 + notes: + - text: 0730? whatsapp checkin + shadow: true + - date: '2025-06-17' + label: Day 2 + sessions: + - start: '08:30' + end: '09:15' + kind: activity + track: main + title: (Hidden) assumptions of simple ODE models + instructors: + - vanschalkwyk + location: main-hall + links: + - text: (Hidden) assumptions of simple ODE models + url: https://drive.google.com/file/d/1tYkR9t3bzmq1iD4fesaXzxgZIeJHWeTY + notes: + - text: note takers {% include instructors people="nyamai" %} + shadow: true + - start: '09:15' + end: '10:00' + kind: live-coding + track: main + title: Introduction to model implementation in R + instructors: + - kassanjee + location: main-hall + links: + - text: Introduction to model implementation in R + url: https://drive.google.com/file/d/1YTiZZGaW17Mx92hmJsCS2qV464wkxL9E + notes: + - '[Resulting Code](https://drive.google.com/file/d/1GFA1k7ckK5lvpml-05tW1u4xqE5EQrrJ)' + - text: 'Notes: Carl' + shadow: true + - start: '10:00' + end: '10:30' + kind: coffee + track: main + - start: '10:30' + end: '11:30' + kind: activity + track: main + title: 'R Tutorials: Lab 1 - ODE Models' + instructors: + - luka + location: comp-lab + links: + - text: R Tutorials + url: ../tutorials + - text: Lab 1 - ODE Models + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_Lab_ODEmodels.R + notes: + - '[Lab 1 Summary](https://drive.google.com/file/d/1iHSjqrwj_LsK8pzAka_OZjepVaLGljH3)' + - start: '11:30' + end: '12:30' + kind: activity + track: main + title: Thinking about Data + instructors: + - nyamai + - Mentors + location: main-hall + links: + - text: Thinking about Data + url: https://docs.google.com/presentation/d/1zygkn_iRYJT4hVigEJuP3siy1GCGkgXX + notes: + - text: note takers Seth + shadow: true + - start: '12:30' + end: '14:00' + kind: meal + meal: lunch + track: main + - start: '14:00' + end: '15:00' + kind: lecture + track: main + title: Study design + instructors: + - kassanjee + location: main-hall + links: + - text: Study design + url: https://drive.google.com/file/d/1de7qMKvnC6djx6CJYIZ8slEt-LOD5-Rh + notes: + - text: 'note takers: Jonathan' + shadow: true + - start: '15:00' + end: '17:00' + kind: activity + track: main + title: '(with tea break) R Tutorials: Lab 4 - study design II & Tutorial 4 - Visualizing Infectious + Disease Data' + instructors: + - blumberg + - kassanjee + - Mentors + location: comp-lab + links: + - text: R Tutorials + url: ../tutorials + - text: Lab 4 - study design II + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_Lab_RCT.R + - text: Tutorial 4 - Visualizing Infectious Disease Data + url: ../tutorials/visualizeData + notes: + - '[Study Design Slides](https://drive.google.com/file/d/1ZrNvXJeJMx8Ei-b4fopwYd_fNG5FAKbG)' + - '[Visualization Summary](https://drive.google.com/file/d/1I_waaz-S3S85s5G0z61ZrIwE-b99oU_N)' + - '[Lab 3 - study design I](https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_Lab_EpiStudyDesign.R) + may also be of interest, but is an own-time activity' + - start: '15:30' + end: '16:00' + kind: tea + track: main + title: (poster setup, group 2) + instructors: + - nyamai + - Mentors + location: main-hall + links: + - text: poster setup, group 2 + url: https://drive.google.com/drive/folders/19DlY2bPRbzAsHIux5nSeBibbCSXlKBrU + - start: '17:00' + end: '18:00' + kind: activity + track: main + title: Poster session 2 + location: main-hall + links: + - text: Poster session 2 + url: https://drive.google.com/drive/folders/19DlY2bPRbzAsHIux5nSeBibbCSXlKBrU + - start: '19:00' + end: '20:00' + kind: lecture + track: main + title: 'Guest Lecture: "Mathematical models of vector-borne diseases: from theory to research" Joseph + Challenger' + links: + - text: 'Guest Lecture: "Mathematical models of vector-borne diseases: from theory to research"' + url: https://docs.google.com/presentation/d/1Gb49d0V84EljTYoulxPQyuiwEEy2jRSV + - text: Joseph Challenger + url: https://scholar.google.com/citations?user=q3Inh-AAAAAJ&hl=en&oi=ao + notes: + - Moderator {% include instructors people="dushoff" %} + links: + - text: End of Day Questions + url: https://forms.gle/ioZ2PLLsjo9WsJH98 + notes: + - text: 0730? whatsapp checkin + shadow: true + - date: '2025-06-18' + label: Day 3 + sessions: + - start: '08:30' + end: '09:30' + kind: lecture + track: main + title: Consequences of heterogeneity and modelling options + instructors: + - dushoff + location: main-hall + notes: + - text: 'Notes: Seth' + shadow: true + - start: '09:30' + end: '11:00' + kind: computer-session + track: main + title: (with coffee break) Lab 2 - Consequences of heterogeneity + instructors: + - dushoff + location: comp-lab + notes: + - text: Mentors, Seth, Joe, CarL + shadow: true + - Download lab + and + supplementary functions + - '[Summary _updated_](https://drive.google.com/file/d/1rSE_99TIUySW-ra0iSb0muFCxR7KbyEb/view?usp=drive_link) + ({% include instructors people="dushoff" %})' + - start: '10:00' + end: '10:30' + kind: coffee + track: main + - start: '11:00' + end: '14:30' + kind: computer-session + track: main + title: 'Introduction to models and data: HIV in Harare' + instructors: + - kassanjee + - pearson + location: comp-lab + links: + - url: https://drive.google.com/file/d/1tFthcPurLo4zvs9j9huFEKJ4pueLICAx + notes: + - 'runs 11:00-14:30 with a lunch break; triaged from an undefined {{ mlect }} macro that rendered empty in the wall' + - text: 'Notes: Mutono' + shadow: true + - Launch from R prompt with `ICI3D::hivTutorial()` + - If you finish all five versions of the model for the Harare data before lunch, move on to working + on data from other countries. + - '**Additional info:** [Distributed Delay Models of Survival](../tutorials/distributedDelay.pdf) + (Boxcar Models) and [example script](https://www.dropbox.com/s/ykirgmodga2j7m9/distributed_delay_boxcar.R?dl=1)' + - text: '[Summary of Harare tutorial]() ({% include instructors people="pearson" %})' + shadow: true + - start: '12:30' + end: '13:00' + kind: activity + track: main + title: faculty meeting + shadow: true + - start: '12:30' + end: '14:00' + kind: meal + meal: lunch + track: main + - start: '14:30' + end: '15:30' + kind: discussion + track: main + title: Formulating research questions + instructors: + - vanschalkwyk + - blumberg + location: sections + notes: + - text: AIMS in main room assisted by Mutono + shadow: true + - text: Seth somewhere notes by Rehsma + shadow: true + - start: '15:30' + end: '16:00' + kind: tea + track: main + - start: '16:00' + end: '17:30' + kind: lecture + track: main + title: Introduction to statistical philosophy + instructors: + - dushoff + location: main-hall + links: + - text: Introduction to statistical philosophy + url: https://drive.google.com/file/d/199YBtpaM6Rl9-UitW7LCyX6OrFLnH1gp + notes: + - text: 'note takers: Cari' + shadow: true + - start: '17:30' + end: '18:00' + kind: activity + track: main + title: 'Projects: Topic Intro' + instructors: + - blumberg + location: main-hall + links: + - text: 'Projects: Topic Intro' + url: https://docs.google.com/presentation/d/1DzepzN9YnYyzhpiV4JKs6x10b2I6B5Pe + notes: + - text: 'note takers: Mutono' + shadow: true + - start: '19:00' + end: '20:00' + kind: activity + track: main + title: 'Optional: Tutorials catch-up' + instructors: + - Mentors + location: comp-lab + links: + - text: Tutorials + url: ../tutorials + links: + - text: End of Day Questions + url: https://forms.gle/K1hNVfazuXbWVvEbA + notes: + - text: 0730? whatsapp checkin + shadow: true + - date: '2025-06-19' + label: Day 4 + sessions: + - start: '08:30' + end: '10:00' + kind: discussion + track: main + title: Creating a model world to address a research question + instructors: + - blumberg + - nyamai + location: sections + notes: + - text: 'note takers: Cari, Jonathan' + shadow: true + - start: '10:00' + end: '10:30' + kind: coffee + track: main + - start: '10:30' + end: '11:15' + kind: lecture + track: main + title: Introduction to likelihood + instructors: + - vanschalkwyk + location: main-hall + links: + - text: Introduction to likelihood + url: https://drive.google.com/file/d/1cQqKXN9DqQ3a3v5HYraWbGOxPbzkGXgX + notes: + - text: 'note takers: Carl' + shadow: true + - start: '11:15' + end: '12:30' + kind: computer-session + track: main + title: Lab 5 - Introduction to likelihood + instructors: + - vanschalkwyk + - Mentors + location: comp-lab + links: + - text: Lab 5 - Introduction to likelihood + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_Lab_introLikelihood.R + notes: + - text: 'helping: Jonathan, Seth, mentors' + shadow: true + - start: '12:30' + end: '13:00' + kind: activity + track: main + title: faculty meeting + shadow: true + - start: '12:30' + end: '14:00' + kind: meal + meal: lunch + track: main + - start: '14:00' + end: '15:30' + kind: live-coding + track: main + title: Participatory coding of a dynamical model + instructors: + - dushoff + location: main-hall + notes: + - '[This years code](https://github.com/ICI3D/RTutorials/blob/master/participatoryDynamics2025.R) + (past years generally available [here](https://github.com/ICI3D/RTutorials))' + - text: 'note takers: Carl' + shadow: true + - start: '15:30' + end: '16:00' + kind: tea + track: main + - start: '16:00' + end: '17:00' + kind: lecture + track: main + title: Introduction stochastic simulation models + instructors: + - kassanjee + location: main-hall + links: + - text: Introduction stochastic simulation models + url: https://drive.google.com/file/d/1zMJWRqbgHpT-PhE88qXmurhvHOh-rCth + notes: + - text: 'note takers: Carii' + shadow: true + - text: Drum on the drums; remind them about assessment + shadow: true + - start: '17:00' + end: '18:00' + kind: discussion + track: main + title: 'Exercise 1: Stochastic models' + instructors: + - kassanjee + location: comp-lab + links: + - text: 'Exercise 1: Stochastic models' + url: https://github.com/ICI3D/RTutorials/blob/master/ICI3D_Example_StochasticSpillover.R + notes: + - '[Summary](https://drive.google.com/file/d/12Km8PtWUdyHf4FQBSwZtDGAxfOc-76Fv)' + - text: 'Support: Mutono, Jonathan, Seth' + shadow: true + - start: '18:30' + end: '19:00' + kind: activity + track: main + title: Finish Model Diagram + - start: '19:00' + end: '21:00' + kind: social + track: main + title: Drumming + location: main-hall + links: + - text: End of Day Questions + url: https://forms.gle/WjMN2aD7HksgrZLv7 + notes: + - text: 0730? whatsapp checkin + shadow: true + - date: '2025-06-20' + label: Day 5 + sessions: + - start: '08:30' + end: '10:00' + kind: discussion + track: main + title: Description of proposed model and assumptions + instructors: + - nyamai + - vanschalkwyk + - blumberg + - Mentors + location: sections + notes: + - text: Telephone! + shadow: true + - text: 'note takers: Team, Reshma' + shadow: true + - start: '10:00' + end: '10:30' + kind: coffee + track: main + - start: '10:30' + end: '11:15' + kind: lecture + track: main + title: Fitting I + instructors: + - vanschalkwyk + location: main-hall + links: + - text: Fitting I + url: https://drive.google.com/file/d/1u0wD9ImMKiLtOvbbqQx7QkL3DNENZ_4f + notes: + - text: 'note takers: {% include instructors people="nyamai" %}' + shadow: true + - start: '11:15' + end: '12:15' + kind: activity + track: main + title: Mentor presentations + instructors: + - blumberg + location: main-hall + notes: + - text: We don't take formal notes here; you are requested to communicate privately with the mentors + shadow: true + - text: 'Seth: mention that lunch not served until 12:30' + shadow: true + - start: '12:15' + end: '12:45' + kind: activity + track: main + title: faculty meeting + shadow: true + - start: '12:15' + end: '13:45' + kind: meal + meal: lunch + track: main + title: (note slight shift) + - start: '13:45' + end: '15:00' + kind: computer-session + track: main + title: Lab 6 - MLE fitting of an SIR model to prevalence data + instructors: + - blumberg + location: comp-lab + links: + - text: Lab 6 - MLE fitting of an SIR model to prevalence data + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_Lab_MLE_SIV_HIV.R + notes: + - text: Jonathan, Reshma, CarL, Carii, mentors + shadow: true + - '[Intro / Summary Slides](https://docs.google.com/presentation/d/1Yj0a1-2-1M8Cg0HH6PiPrc6cdKGGH1ru)' + - start: '15:00' + end: '15:30' + kind: activity + track: main + title: 'Projects: Topic Qs' + instructors: + - nyamai + location: main-hall + links: + - text: 'Projects: Topic Qs' + url: https://docs.google.com/presentation/d/1DzepzN9YnYyzhpiV4JKs6x10b2I6B5Pe + notes: + - text: 'note takers: Jonathan, Seth' + shadow: true + - start: '15:30' + end: '16:00' + kind: tea + track: main + - start: '16:00' + end: '17:00' + kind: activity + track: main + title: Mid-Clinic Feeback session + instructors: + - bruce + location: main-hall + - start: '17:00' + end: '18:00' + kind: activity + track: main + title: Optional catchup session + location: comp-lab + links: + - text: Optional catchup session + url: https://docs.google.com/forms/d/10c8MwOpJYpToRikrSXSHOWjKg80xduucFMKa9A4BDh0 + notes: + - Let the mentors know your questions + - start: '19:00' + end: '21:00' + kind: social + track: main + title: Movie Night + location: main-hall + notes: + - text: 'Project group: work on group assignments possibly calling on experts' + shadow: true + links: + - text: End of Day Questions + url: https://forms.gle/55XBgBjKi34jFPVJ9 + notes: + - text: 0730? whatsapp checkin + shadow: true + - '**NO LATER THAN 1800: [Project selection form](https://docs.google.com/forms/d/1RJW6X5RFQ0ZdSu7cqe3T-SvNsoIksQFhpbaaXWCD9NY)**' + - date: '2025-06-21' + label: Day 6 + sessions: + - start: '09:00' + end: '10:30' + kind: live-coding + track: main + title: Participatory coding for stochastic model + instructors: + - pearson + location: main-hall + notes: + - text: 'note takers: Reshma' + shadow: true + - start: '10:30' + end: '11:00' + kind: coffee + track: main + - start: '11:00' + end: '11:30' + kind: activity + track: main + title: 'Projects: Group Assignments' + instructors: + - nyamai + location: comp-lab + links: + - text: 'Projects: Group Assignments' + url: https://docs.google.com/spreadsheets/d/1oYApOiOTl1C8M2E9QYEJjZeQCL1ErvWmSwrTD8B01AQ/ + - start: '11:30' + end: '12:30' + kind: activity + track: main + title: 'Optional: Tutorials catch-up or Project Group Work' + instructors: + - Mentors + location: comp-lab + links: + - text: Tutorials + url: ../tutorials + notes: + - text: 0730? whatsapp checkin + shadow: true + - '{{ sc }} Optional: Group Lunch at Kalky''s (costs covered)' + - text: 16h30 post-Kalky's all hands faculty meeting in E313; beverages provided + shadow: true + - date: '2025-06-22' + label: Day 7 + sessions: [] + notes: + - '{{ sc }} Social Outing: Cape Point National Park; depart 09h00 sharp' +- title: 'Week 2: MMED Project Focus' + collapsible: true + open: true + days: + - date: '2025-06-23' + label: Day 8 (Monday, 23 June) + sessions: + - start: '08:30' + end: '08:45' + kind: discussion + track: main + title: Feedback responses; Review Schedule & goals + instructors: + - kassanjee + location: main-hall + notes: + - text: 'Notes: Seth' + shadow: true + - start: '08:45' + end: '09:30' + kind: lecture + track: main + title: Life cycle of a modeling project + instructors: + - pearson + location: main-hall + links: + - text: Life cycle of a modeling project + url: https://docs.google.com/presentation/d/1QluWYDhtsHUZK4WSY_Ddq7u8tJe3pG7N + notes: + - text: 'note takers: Mutono, Joe' + shadow: true + - start: '09:30' + end: '10:00' + kind: group-work + track: main + title: First meeting of project groups + location: group-breakouts + links: + - text: project groups + url: https://docs.google.com/spreadsheets/d/1oYApOiOTl1C8M2E9QYEJjZeQCL1ErvWmSwrTD8B01AQ/ + - start: '10:00' + end: '10:30' + kind: coffee + track: main + - start: '10:30' + end: '11:30' + kind: activity + track: main + title: Introduction to github + instructors: + - pearson + - Mentors + location: comp-lab + links: + - text: Introduction to github + url: https://docs.google.com/presentation/d/1Y0pTtI3DPKuE6ZKJ_gyfutFnjTJy3VkD + notes: + - text: 'note takers: Cari' + shadow: true + - start: '11:30' + end: '12:30' + kind: group-work + track: main + title: MMED project work + location: group-breakouts + - start: '12:30' + end: '14:00' + kind: meal + meal: lunch + track: main + - start: '14:00' + end: '15:30' + kind: lecture + track: main + title: Likelihood fitting and dynamical models II + instructors: + - blumberg + location: main-hall + links: + - text: Likelihood fitting and dynamical models II + url: https://docs.google.com/presentation/d/1AdBlXce06jznzo1tyywdiwqNs_UZIN6P + notes: + - text: 'Notes: Cari, Jonathan' + shadow: true + - text: Announce github practice no need to sign up + shadow: true + - start: '15:30' + end: '16:00' + kind: tea + track: main + - start: '16:00' + end: '18:00' + kind: group-work + track: main + title: MMED project work + mentoring sessions + location: group-breakouts + links: + - text: mentoring sessions + url: https://docs.google.com/spreadsheets/d/1jzPPM6DxPewEEFea2nAPiadjunCzKu_dpwgoVei6658 + - start: '19:00' + end: '20:00' + kind: activity + track: main + title: 'Optional: Github practice and and trouble-shooting / tutorial catch-up' + instructors: + - pearson + - Mentors + location: comp-lab + links: + - text: End of Day Questions + url: https://forms.gle/ycGw6LvfUfqK3THw7 + - date: '2025-06-24' + label: Day 9 + sessions: + - start: '08:30' + end: '10:00' + kind: lecture + track: main + title: Introduction to Markov Chain Monte Carlo (MCMC) + instructors: + - pearson + location: main-hall + notes: + - text: Seth, Mutono + shadow: true + - start: '10:00' + end: '10:30' + kind: coffee + track: main + - start: '10:30' + end: '11:30' + kind: computer-session + track: main + title: Lab 7 & Lab 8 MCMC model fitting + instructors: + - pearson + - Mentors + location: comp-lab + links: + - text: Lab 7 + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_Lab_MCMC-Binomial.R + - text: Lab 8 + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_Lab_MCMC-SI_HIV.R + notes: + - text: Cari, Seth, Jonathan + shadow: true + - start: '11:30' + end: '12:30' + kind: group-work + track: main + title: MMED project work + location: group-breakouts + - start: '12:30' + end: '14:00' + kind: meal + meal: lunch + track: main + - start: '14:00' + end: '14:45' + kind: lecture + track: main + title: Data management and cleaning + instructors: + - nyamai + location: main-hall + links: + - text: Data management and cleaning + url: https://drive.google.com/file/d/1Ub-UmIWcCso-r6p_GZII3c5zigO4NL3Q + notes: + - text: 'Notes: Jonathan' + shadow: true + - start: '14:45' + end: '15:30' + kind: computer-session + track: main + title: 'Tutorial 5: Data cleaning, data' + instructors: + - bingham + location: comp-lab + links: + - text: 'Tutorial 5: Data cleaning' + url: https://raw.githubusercontent.com/ICI3D/RTutorials/master/ICI3D_RTutorial_5_DataCleaning.R + - text: data + url: https://github.com/ICI3D/datasets/blob/master/dataCleaning/tutorial5.csv + notes: + - '[Summary]() ({% include instructors people="Mentors" %})' + - text: 'note takers: Mutono, Cari' + shadow: true + - start: '15:30' + end: '16:00' + kind: tea + track: main + - start: '16:00' + end: '18:00' + kind: group-work + track: main + title: MMED project work + mentoring sessions + location: group-breakouts + links: + - text: mentoring sessions + url: https://docs.google.com/spreadsheets/d/1jzPPM6DxPewEEFea2nAPiadjunCzKu_dpwgoVei6658 + - start: '19:00' + end: '20:00' + kind: activity + track: main + title: 'Optional: Github Trouble-shooting / tutorial catch-up' + instructors: + - Mentors + location: comp-lab + links: + - text: End of Day Questions + url: https://forms.gle/ydGJTpyQuBner1Wd7 + - date: '2025-06-25' + label: Day 10 + sessions: + - start: '08:30' + end: '09:30' + kind: lecture + track: main + title: Model assessment + instructors: + - dushoff + location: main-hall + links: + - text: Model assessment + url: https://drive.google.com/file/d/1qyncr5wiEdAaR6EP36M9MUYwh8Ktj_Xk + notes: + - text: 'Notes: Joe, Cari' + shadow: true + - start: '09:30' + end: '10:00' + kind: group-work + track: main + title: MMED project work + location: group-breakouts + - start: '10:00' + end: '10:30' + kind: coffee + track: main + - start: '10:30' + end: '11:00' + kind: group-work + track: main + title: MMED project work + location: group-breakouts + - start: '11:00' + end: '12:30' + kind: group-work + track: main + title: '**either** MMED project work **or** Analysis pipelines (resulting code)' + instructors: + - pearson + location: group-breakouts + notes: + - text: 'note takers: Carl' + shadow: true + - text: Get a more direct summary title for this + shadow: true + - start: '12:30' + end: '14:00' + kind: meal + meal: lunch + track: main + - start: '14:00' + end: '15:30' + kind: group-work + track: main + title: MMED project work + location: group-breakouts + - start: '15:30' + end: '16:00' + kind: tea + track: main + - start: '16:00' + end: '18:00' + kind: group-work + track: main + title: MMED project work + mentoring sessions + location: group-breakouts + links: + - text: mentoring sessions + url: https://docs.google.com/spreadsheets/d/1jzPPM6DxPewEEFea2nAPiadjunCzKu_dpwgoVei6658 + - start: '19:00' + end: '20:00' + kind: activity + track: main + title: 'Guest Lecture: Mmamapudi Kubjane' + location: main-hall + links: + - text: Mmamapudi Kubjane + url: https://www.ici3d.org/DAIDD/team/kubjane/ + links: + - text: End of Day Questions + url: https://forms.gle/MUPLB5WP9RUqWUA47 + - date: '2025-06-26' + label: Day 11 + sessions: + - start: '08:30' + end: '10:00' + kind: group-work + track: main + title: MMED project work + mentoring sessions + location: group-breakouts + links: + - text: mentoring sessions + url: https://docs.google.com/spreadsheets/d/1jzPPM6DxPewEEFea2nAPiadjunCzKu_dpwgoVei6658 + - start: '10:00' + end: '10:30' + kind: coffee + track: main + - start: '10:30' + end: '12:30' + kind: group-work + track: main + title: MMED project work + location: group-breakouts + - start: '12:30' + end: '14:00' + kind: meal + meal: lunch + track: main + - start: '14:00' + end: '15:30' + kind: group-work + track: main + title: '**either** MMED project work **or** Participatory coding for study design and simulation + based validation' + instructors: + - dushoff + location: main-hall + notes: + - text: 'note takers: {% include instructors people="pearson" %}' + shadow: true + - text: '[One-study code](https://github.com/ICI3D/RTutorials/blob/master/sampling_JD/study_design.R); + [Many-study code](https://github.com/ICI3D/RTutorials/blob/master/sampling_JD/study_design_rep.R)' + shadow: true + - start: '15:30' + end: '16:00' + kind: tea + track: main + - start: '16:00' + end: '17:30' + kind: discussion + track: main + title: Modelling for policy + instructors: + - blumberg + location: main-hall + notes: + - text: 'note takers: {% include instructors people="dushoff" %}' + shadow: true + - start: '19:00' + end: '20:00' + kind: activity + track: main + title: MMED project work + - date: '2025-06-27' + label: Day 12 + sessions: + - start: '08:30' + end: '12:30' + kind: group-work + track: main + title: MMED project work + location: group-breakouts + - start: '10:00' + end: '10:30' + kind: coffee + track: main + notes: + - 12h30 [Report due]() + - 13h00 [Presentations due]() + - start: '12:30' + end: '13:30' + kind: meal + meal: lunch + track: main + - start: '13:30' + end: '15:30' + kind: group-work + track: main + title: Project presentations + instructors: + - nyamai + location: main-hall + notes: + - text: 'note takers: {% include instructors people="dushoff" %}' + shadow: true + - start: '15:30' + end: '16:00' + kind: tea + track: main + - start: '16:00' + end: '17:00' + kind: discussion + track: main + title: Feedback session II + instructors: + - bruce + location: main-hall + - start: '17:15' + end: '17:45' + kind: activity + track: main + title: Closing Remarks + instructors: + - kassanjee + location: main-hall + notes: + - text: 'note takers: {% include instructors people="TBD" %}' + shadow: true diff --git a/_data/schedule/roles.yml b/_data/schedule/roles.yml new file mode 100644 index 0000000..96b034d --- /dev/null +++ b/_data/schedule/roles.yml @@ -0,0 +1,19 @@ +# Shared role tokens for ICI3D clinic schedules (#54 data loop). +# +# An `instructors:` / `faculty:` entry in a schedule YAML is one of: +# - a person KEY that must resolve to _data/team/.yml (hard-checked, Tier 1a) +# - a ROLE token listed here (allowed, no person record) +# - an external object {name, url} (skipped by Tier 1) +# +# Role tokens are matched case-insensitively by tools/validate_schedule.py, so the +# schedule may write "Tutors" / "Mentors" / "everyone" and they resolve here. +# `tbd` is allowed but emits a non-gating WARNING (a placeholder still to be filled). +roles: + - everyone # the whole cohort (faculty + participants) + - all # synonym for everyone + - faculty # the assembled faculty, unspecified individuals + - mentors # project mentors as a group + - tutors # R/computer-session tutors as a group + - aims-tutors # AIMS tutors as a group + - organizers # the organizing team + - tbd # placeholder — allowed but warns diff --git a/schemas/schedule-cohort.schema.json b/schemas/schedule-cohort.schema.json new file mode 100644 index 0000000..a194ad9 --- /dev/null +++ b/schemas/schedule-cohort.schema.json @@ -0,0 +1,181 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ici3d.org/schemas/schedule-cohort.schema.json", + "title": "ICI3D clinic schedule (one cohort-year)", + "type": "object", + "required": ["timezone", "tracks", "weeks"], + "additionalProperties": false, + "properties": { + "clinic": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "year": { "type": "integer", "minimum": 2000, "maximum": 2100 }, + "status": { "enum": ["draft", "published", "archive"], "default": "published" }, + "title": { "type": "string" }, + "location_name": { "type": "string" }, + "timezone": { "$ref": "#/$defs/ianaTz" }, + "display_timezones": { "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "$ref": "#/$defs/ianaTz" } }, + "tracks": { "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } }, + "faculty": { "type": "array", "uniqueItems": true, + "items": { "$ref": "#/$defs/personKey" } }, + "locations": { "type": "object", + "additionalProperties": { "type": "string", "minLength": 1 } }, + "meal_defaults": { "type": "object", + "additionalProperties": { + "type": "object", "additionalProperties": false, + "required": ["start", "end"], + "properties": { "start": { "$ref": "#/$defs/hhmm" }, + "end": { "$ref": "#/$defs/hhmm" } } } }, + "weeks": { "type": "array", "minItems": 1, + "items": { "$ref": "#/$defs/week" } } + }, + "$defs": { + "hhmm": { "type": "string", "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$" }, + "isoDate": { "type": "string", "format": "date", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, + "ianaTz": { "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9_+\\-]*(/[A-Za-z0-9_+\\-]+)+$" }, + "personKey": { "type": "string", "pattern": "^[a-z][a-z0-9_-]*$" }, + "instructorKey": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]*$" }, + + "external": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "url": { "type": "string", "minLength": 1 } + } + }, + + "instructor": { + "oneOf": [ + { "$ref": "#/$defs/instructorKey" }, + { "$ref": "#/$defs/external" } + ] + }, + + "link": { + "type": "object", + "required": ["url"], + "additionalProperties": false, + "properties": { + "text": { "type": "string", "minLength": 1 }, + "url": { "type": "string", "minLength": 1 }, + "deadline": { "$ref": "#/$defs/hhmm" } + } + }, + + "note": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { "type": "object", + "required": ["text"], + "additionalProperties": false, + "properties": { + "text": { "type": "string", "minLength": 1 }, + "shadow": { "type": "boolean", "default": false } + } } + ] + }, + + "week": { + "type": "object", + "required": ["days"], + "additionalProperties": false, + "properties": { + "title": { "type": "string" }, + "collapsible": { "type": "boolean", "default": false }, + "open": { "type": "boolean", "default": false }, + "days": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/day" } } + } + }, + + "day": { + "type": "object", + "required": ["date"], + "additionalProperties": false, + "properties": { + "date": { "$ref": "#/$defs/isoDate" }, + "label": { "type": "string" }, + "sessions": { "type": "array", "items": { "$ref": "#/$defs/session" } }, + "links": { "type": "array", "items": { "$ref": "#/$defs/link" } }, + "notes": { "type": "array", "items": { "$ref": "#/$defs/note" } } + } + }, + + "session": { + "type": "object", + "required": ["kind"], + "additionalProperties": false, + "properties": { + "kind": { + "enum": ["lecture", "discussion", "computer-session", "live-coding", + "group-work", "organizing", "activity", "poster", "reading", + "social", "meal", "coffee", "tea", "break", + "note", "raw", "todo"] + }, + "start": { "$ref": "#/$defs/hhmm" }, + "end": { "$ref": "#/$defs/hhmm" }, + "track": { "type": "string", "minLength": 1 }, + "tracks": { "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } }, + "instructors_by_track": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "$ref": "#/$defs/instructor" } } }, + "choice": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "shadow": { "type": "boolean", "default": false }, + "guest": { "type": "boolean", "default": false }, + "speaker": { "type": "string", "minLength": 1 }, + "instructors": { "type": "array", "items": { "$ref": "#/$defs/instructor" } }, + "location": { "type": "string", "minLength": 1 }, + "links": { "type": "array", "items": { "$ref": "#/$defs/link" } }, + "notes": { "type": "array", "items": { "$ref": "#/$defs/note" } }, + "time_note": { "type": "string" }, + "meal": { "enum": ["breakfast", "lunch", "dinner"] }, + "text": { "type": "string" }, + "html": { "type": "string" }, + "source": { "type": "string" } + }, + "not": { "required": ["track", "tracks"] }, + "allOf": [ + { + "if": { "properties": { "kind": { "enum": + ["lecture", "discussion", "computer-session", "live-coding", + "group-work", "organizing", "activity", "poster", "reading"] } }, + "required": ["kind"] }, + "then": { "required": ["start", "end", "title"], + "anyOf": [ { "required": ["track"] }, { "required": ["tracks"] } ] } + }, + { + "if": { "properties": { "kind": { "enum": ["coffee", "tea", "break"] } }, + "required": ["kind"] }, + "then": { "required": ["start", "end"] } + }, + { + "if": { "properties": { "kind": { "const": "meal" } }, "required": ["kind"] }, + "then": { "required": ["start", "end", "meal"] } + }, + { + "if": { "properties": { "kind": { "const": "social" } }, "required": ["kind"] }, + "then": { "required": ["title"] } + }, + { + "if": { "properties": { "kind": { "const": "note" } }, "required": ["kind"] }, + "then": { "required": ["text"], "properties": { "shadow": { "const": true } } } + }, + { + "if": { "properties": { "kind": { "const": "raw" } }, "required": ["kind"] }, + "then": { "required": ["html"] } + }, + { + "if": { "properties": { "kind": { "const": "todo" } }, "required": ["kind"] }, + "then": { "required": ["source"] } + } + ] + } + } +} diff --git a/tools/import_schedule.py b/tools/import_schedule.py new file mode 100644 index 0000000..dd6a36d --- /dev/null +++ b/tools/import_schedule.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""One-shot migration: an MMED Liquid-wall schedule (schedule//index.md) -> cohort YAML (#54). + +This is a MIGRATION tool, run once per cohort, NOT the live renderer. Its contract is the one +the design rests on: it NEVER silently drops a line. Every source bullet becomes either a typed +session / day-level link / day-level note, or a `kind: todo` row carrying the original text verbatim. +A `todo` parses under the schema but HARD-FAILS Tier 1, so CI turns every line the importer could not +confidently map into a concrete, addressable error for a human to triage (vs. today's silent failures). + + python3 tools/import_schedule.py schedule/2025/index.md --year 2025 \ + --clinic mmed --status published -o _data/schedule/mmed/2025.yml + +Then validate the result: + python3 tools/validate_schedule.py _data/schedule/mmed/2025.yml +""" +from __future__ import annotations +import argparse +import re +import sys +from pathlib import Path + +import yaml + +# ---- {{ token }} vocabulary (from the assign preamble) -> closed kind enum ---- +KIND = { + "lect": ("lecture", {}), "glect": ("lecture", {"guest": True}), + "disc": ("discussion", {}), + "prac": ("computer-session", {}), "labex": ("computer-session", {}), + "labs": ("computer-session", {}), "rtut": ("computer-session", {}), + "ex": ("activity", {}), + "gw": ("group-work", {}), + "lc": ("live-coding", {}), "pc": ("live-coding", {}), + "sc": ("social", {}), + "org": ("organizing", {}), + "post": ("poster", {}), + "catch": ("activity", {}), "proj": ("activity", {}), +} +MEAL = {"bfast": "breakfast", "ssbfast": "breakfast", "lunch": "lunch", + "dinner": "dinner", "ssdinner": "dinner"} +LOGISTIC = {"coffee": "coffee", "tea": "tea", "break": "break"} +LOC = {"main": "main-hall", "lab": "comp-lab", "breakout": "group-breakouts", + "lobby": "lobby", "sections": "sections"} +LOCATIONS_LABEL = {"main-hall": "Main Hall", "comp-lab": "Comp. Lab", + "group-breakouts": "Group Breakouts", "lobby": "Lobby", + "sections": "Section 1 / Section 2"} +MONTHS = {m: i for i, m in enumerate( + ["january", "february", "march", "april", "may", "june", "july", "august", + "september", "october", "november", "december"], 1)} + +RE_SUMMARY = re.compile(r"]*>(.*?)", re.I) +RE_DETAILS_OPEN = re.compile(r"]*\bopen\b", re.I) +RE_DAY = re.compile(r"^#{2,3}\s*Day\s*([0-9A-Za-z]+)\s*(?:\(([^)]*)\))?\s*$", re.I) +RE_TIME = re.compile(r"^(\d{1,2})h(\d{2})\s*[-–]\s*(\d{1,2})h(\d{2})\s*(.*)$") +RE_TOKEN = re.compile(r"\{\{\s*(\w+)\s*\}\}") +RE_SHADOW = re.compile(r"\{:\s*\.shadow\s*\}") # kramdown faculty-only marker; tolerates inner spaces +RE_PEOPLE = re.compile(r'people\s*=\s*"([^"]+)"') +RE_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") +RE_ANYLINK = re.compile(r"\[([^\]]+)\]\(([^)]*)\)") # also matches empty-url [text]() +RE_DATEPROSE = re.compile(r"(\d{1,2})\s+([A-Za-z]+)") + + +def hhmm(h, m): + return f"{int(h):02d}:{int(m):02d}" + + +def split_people(meta): + """Pull instructor strings out of a trailing meta group like + ({% include instructors people="a, b" %}, {{ main }}) or (everyone, {{ main }}).""" + people = [] + for grp in RE_PEOPLE.findall(meta): + # the source separates co-instructors with both "," and "|" + people += [x.strip() for x in re.split(r"[,|]", grp) if x.strip()] + if not people: + # bare names before the location token: strip tokens/includes, take leading words + bare = RE_TOKEN.sub("", meta) + bare = re.sub(r"\{%.*?%\}", "", bare) + bare = bare.strip().strip("()").strip() + for chunk in re.split(r"[,|]", bare): + c = chunk.strip() + if c and "include" not in c and "instructors" not in c: + people.append(c) + return people + + +def parse_meta_group(rest): + """Given the text after the time range, separate (title+links) from the trailing + ( ...people... {{ loc }} ) meta group. Returns (body_without_meta, location, people).""" + location = None + people = [] + # find the LAST parenthesised group that carries a location token, people=, or an instructors include + best = None + for mo in re.finditer(r"\(([^()]*(?:\([^()]*\)[^()]*)*)\)", rest): + inner = mo.group(1) + if RE_PEOPLE.search(inner) or "instructors" in inner or RE_TOKEN.search(inner) \ + or re.search(r"\b(everyone|all)\b", inner, re.I): + best = mo + if best: + inner = best.group(1) + toks = RE_TOKEN.findall(inner) + location = next((LOC[t] for t in toks if t in LOC), None) + people = split_people(inner) + rest = (rest[:best.start()] + rest[best.end():]).strip() + return rest, location, people + + +def parse_session(rest, source): + """Build a session dict from the text after a time range (kind/title/links/people/location). + + Returns (sess, title, links, location, people, extra, is_todo). An undefined macro + (a {{token}} that is not a known kind/meal/logistic/location, e.g. {{ mlect }}) is NOT + silently coerced to a generic activity; it becomes a loud `todo` for a human to map. + """ + toks = RE_TOKEN.findall(rest) + kind = subtype = None + extra = {} + for t in toks: + if t in KIND: + kind, extra = KIND[t][0], dict(KIND[t][1]) + break + if t in MEAL: + kind, subtype = "meal", MEAL[t] + break + if t in LOGISTIC: + kind = LOGISTIC[t] + break + unknown = [t for t in toks if t not in KIND and t not in MEAL and t not in LOGISTIC and t not in LOC] + if kind is None and unknown: + return {"kind": "todo", "source": source}, None, [], None, [], {}, True + + body, location, people = parse_meta_group(rest) + body = re.sub(r"\{%.*?%\}", "", body) + body = RE_TOKEN.sub("", body).strip() # drop kind/loc tokens left in the title region + + # links: keep only non-empty urls; tidy {url} when display text duplicates the url + links = [] + for (t, u) in RE_ANYLINK.findall(body): + if u: + links.append({"url": u} if t == u else {"text": t, "url": u}) + # title: inline each link's display text, drop urls and any leftover empty () + title = RE_ANYLINK.sub(lambda m: m.group(1), body) + title = re.sub(r"\(\s*\)", "", title) + title = re.sub(r"\s+", " ", title).strip(" -–·,") + title = title or None + + if kind is None: + kind = "activity" # timed but no recognised token -> generic (the 33 untyped 2025 rows) + + sess = {"kind": kind} + if subtype: + sess["meal"] = subtype + return sess, title, links, location, people, extra, False + + +def infer_dates(days, year): + """Fill day['date'] from the prose in the header parens; carry forward +1 when absent.""" + from datetime import date, timedelta + prev = None + for d in days: + iso = None + prose = d.pop("_dateprose", "") or "" + m = RE_DATEPROSE.search(prose) + if m and m.group(2).lower() in MONTHS: + iso = date(year, MONTHS[m.group(2).lower()], int(m.group(1))) + elif prev is not None: + iso = prev + timedelta(days=1) + if iso is not None: + d["date"] = iso.isoformat() + prev = iso + else: + d["date"] = f"{year}-01-01" # placeholder; Tier 0 still requires a real ISO date + d.setdefault("_needs_date", True) + + +def parse(path, year): + weeks = [] + cur_week = None + cur_day = None + last_session = None # most recent timed session, so indented sub-bullets attach to it + todos = 0 + + def new_week(title, is_open): + nonlocal cur_week, cur_day + cur_week = {"title": title, "collapsible": True, "open": bool(is_open), "days": []} + weeks.append(cur_week) + cur_day = None + + def ensure_week(): + if cur_week is None: + new_week("Schedule", False) + return cur_week + + for raw in Path(path).read_text().splitlines(): + line = raw.rstrip() + s = line.strip() + if not s: + continue + + msum = RE_SUMMARY.search(s) + if msum: + new_week(msum.group(1).strip(), bool(RE_DETAILS_OPEN.search(s))) + last_session = None + continue + mday = RE_DAY.match(s) + if mday: + ensure_week() + cur_day = {"label": ("Day " + mday.group(1) + (f" ({mday.group(2).strip()})" if mday.group(2) else "")), + "_dateprose": (mday.group(2) or ""), "sessions": []} + cur_week["days"].append(cur_day) + last_session = None + continue + + if not s.startswith("- "): + continue + indented = raw[:1] in (" ", "\t") + body = s[2:].strip() + shadow = bool(RE_SHADOW.search(body)) + body = RE_SHADOW.sub("", body).strip() + + # an indented sub-bullet is a note on the PRECEDING session, not a day item + if indented and last_session is not None and not RE_TIME.match(body): + if body: + last_session.setdefault("notes", []).append( + {"text": body, "shadow": True} if shadow else body) + continue + + # top-level untimed bullet under a day -> a day-scoped link or note (never dropped) + if cur_day is not None and not RE_TIME.match(body): + only_link = RE_LINK.fullmatch(body) + if only_link: + cur_day.setdefault("links", []).append( + {"text": only_link.group(1).strip(), "url": only_link.group(2).strip()}) + continue + cur_day.setdefault("notes", []).append( + {"text": body, "shadow": True} if shadow else body) + continue + + mt = RE_TIME.match(body) + if not mt: + # no day context and not a time row -> a TODO the human must place + ensure_week() + if cur_day is None: + cur_day = {"label": "Day ?", "_dateprose": "", "sessions": []} + cur_week["days"].append(cur_day) + cur_day["sessions"].append({"kind": "todo", "source": s}) + todos += 1 + continue + + start, end = hhmm(mt.group(1), mt.group(2)), hhmm(mt.group(3), mt.group(4)) + rest = mt.group(5).strip() + sess, title, links, location, people, extra, is_todo = parse_session(rest, s) + sess["start"], sess["end"] = start, end + if is_todo: + todos += 1 + ordered = {k: sess[k] for k in ["start", "end", "kind", "source"] if k in sess} + else: + if title: + sess["title"] = title + if "track" not in sess: + sess["track"] = "main" + if people: + sess["instructors"] = people + if location: + sess["location"] = location + if links: + sess["links"] = links + if shadow: + sess["shadow"] = True + sess.update(extra) + ordered = {k: sess[k] for k in ["start", "end", "kind", "meal", "track", "title", + "instructors", "location", "links", "notes", "shadow", "guest"] + if k in sess} + ensure_week() + if cur_day is None: + cur_day = {"label": "Day ?", "_dateprose": "", "sessions": []} + cur_week["days"].append(cur_day) + cur_day["sessions"].append(ordered) + last_session = ordered + + for w in weeks: + infer_dates(w["days"], year) + for d in w["days"]: + d.pop("_needs_date", None) + # order day keys: date, label, sessions, links, notes + for k in ("date", "label", "sessions", "links", "notes"): + if k in d: + d[k] = d.pop(k) + return weeks, todos + + +class _Dumper(yaml.SafeDumper): + pass + + +def _str_representer(dumper, data): + # force-quote scalars that YAML would otherwise coerce (dates, HH:MM times) + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", data) or re.fullmatch(r"\d{2}:\d{2}", data): + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="'") + return dumper.represent_scalar("tag:yaml.org,2002:str", data) + + +_Dumper.add_representer(str, _str_representer) + + +def main(argv=None): + ap = argparse.ArgumentParser() + ap.add_argument("source", help="schedule//index.md (the Liquid wall)") + ap.add_argument("--year", type=int, required=True) + ap.add_argument("--clinic", default="mmed") + ap.add_argument("--status", default="published", choices=["draft", "published", "archive"]) + ap.add_argument("-o", "--out", required=True) + args = ap.parse_args(argv) + + weeks, todos = parse(args.source, args.year) + n_sess = sum(len(d.get("sessions", [])) for w in weeks for d in w["days"]) + doc = { + "clinic": args.clinic, + "year": args.year, + "status": args.status, + "title": f"MMED {args.year}", + "timezone": "Africa/Johannesburg", + "display_timezones": ["Africa/Johannesburg"], + "tracks": ["main", "Section 1", "Section 2"], + "locations": LOCATIONS_LABEL, + "meal_defaults": {"breakfast": {"start": "07:45", "end": "08:15"}, + "dinner": {"start": "18:00", "end": "18:30"}}, + "weeks": weeks, + } + Path(args.out).parent.mkdir(parents=True, exist_ok=True) + with open(args.out, "w") as fh: + yaml.dump(doc, fh, Dumper=_Dumper, sort_keys=False, default_flow_style=False, + allow_unicode=True, width=100) + print(f"wrote {args.out}: {len(weeks)} weeks, " + f"{sum(len(w['days']) for w in weeks)} days, {n_sess} sessions, {todos} TODO rows") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/validate_schedule.py b/tools/validate_schedule.py new file mode 100644 index 0000000..d3e8a6f --- /dev/null +++ b/tools/validate_schedule.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Validate ICI3D clinic schedule data files (#54 "flat data -> validate -> render" loop). + +Two gating tiers run here; Tier 2 (link-liveness) is a separate advisory job and is NOT run here. + + Tier 0 - schema: JSON Schema (schemas/schedule-cohort.schema.json). Required-fields-per-kind, + the closed `kind` enum, quoted "HH:MM" / "YYYY-MM-DD" strings (an unquoted + date becomes a YAML date object and fails the `type: string` assert here), + declared-track membership of the shape, etc. + Tier 1 - semantic: (a) every instructor/faculty key resolves to a person record or a role token; + (b) sessions do not overlap within a track (scoped: shadow + logistics/social + + same-`choice` alternatives + untimed rows are exempt); + (c) end >= start; (d) every session.track is a declared track; + (e) timezone + display_timezones are real IANA zones; + (f) no `kind: todo` survives in a non-archive cohort (the importer's loud TODO). + +Usage: + python3 tools/validate_schedule.py _data/schedule/mmed/2025.yml [more.yml ...] \ + [--schema schemas/schedule-cohort.schema.json] \ + [--people-dir ICI3D.github.io/_data/team] \ + [--roles _data/schedule/roles.yml] [--github] + +Exit status is non-zero if any GATING finding (error) is present. Warnings never gate. +""" +from __future__ import annotations +import argparse +import json +import sys +from collections import defaultdict +from datetime import date as _date +from difflib import get_close_matches +from pathlib import Path + +import yaml +from jsonschema import Draft202012Validator + +try: + from zoneinfo import available_timezones + _IANA = available_timezones() +except Exception: # pragma: no cover - zoneinfo always present on 3.9+ + _IANA = None + +# Kinds exempt from the within-track non-overlap check. The real schedules deliberately +# nest a shadow faculty meeting inside lunch, run a coffee break inside a long lab, etc. +NONOVERLAP_EXEMPT_KINDS = {"meal", "coffee", "tea", "break", "note", "social", "raw", "todo"} +_ALL_LANE = "\x00ALL\x00" # sentinel lane for untracked sessions + + +class Finding: + __slots__ = ("level", "where", "msg", "line") + + def __init__(self, level: str, where: str, msg: str, line: int | None = None): + self.level = level # "error" (gates) or "warning" (advisory) + self.where = where + self.msg = msg + self.line = line + + +def _hhmm_to_min(s): + try: + h, m = str(s).split(":") + return int(h) * 60 + int(m) + except Exception: + return None + + +def _iter_sessions(doc): + """Yield (session, locator) for every session in the document.""" + for wi, week in enumerate(doc.get("weeks") or []): + wt = (week or {}).get("title") or f"Week #{wi}" + for di, day in enumerate(week.get("days") or []): + dd = (day or {}).get("date") or (day or {}).get("label") or f"Day #{di}" + for si, sess in enumerate(day.get("sessions") or []): + if not isinstance(sess, dict): + continue + title = sess.get("title") or sess.get("meal") or sess.get("kind") or "?" + start = sess.get("start") or "--:--" + loc = f"{wt} / {dd} / {start} {sess.get('kind','?')} \"{title}\"" + yield sess, loc, day + + +def _collect_instructor_strings(sess): + """All string (key/role) instructor entries on a session; externals/dicts skipped.""" + out = [] + for item in (sess.get("instructors") or []): + if isinstance(item, str): + out.append(item) + for vals in (sess.get("instructors_by_track") or {}).values(): + for item in (vals or []): + if isinstance(item, str): + out.append(item) + return out + + +def validate_doc(path: Path, schema, people_keys, role_tokens) -> list[Finding]: + findings: list[Finding] = [] + raw = path.read_text() + try: + doc = yaml.safe_load(raw) + except yaml.YAMLError as e: + return [Finding("error", str(path), f"YAML did not parse: {e}")] + if not isinstance(doc, dict): + return [Finding("error", str(path), "top-level YAML is not a mapping")] + + status = doc.get("status", "published") + + # ---- Tier 0: schema ---- + for err in sorted(Draft202012Validator(schema).iter_errors(doc), key=lambda e: list(e.path)): + loc = "/".join(str(p) for p in err.path) or "" + findings.append(Finding("error", f"T0 {loc}", err.message)) + + # If the shape is badly broken, semantic checks would just add noise. + if any(f.level == "error" for f in findings): + # Still run the cheap top-level checks that don't depend on shape integrity. + pass + + declared_tracks = set(doc.get("tracks") or []) + + # ---- Tier 1e: timezone validity ---- + if _IANA is not None: + for tz in [doc.get("timezone")] + list(doc.get("display_timezones") or []): + if tz and tz not in _IANA: + findings.append(Finding("error", "T1e timezone", f"'{tz}' is not a known IANA timezone")) + + # ---- Tier 1a: top-level faculty roster resolution ---- + for key in (doc.get("faculty") or []): + if isinstance(key, str): + _resolve_person(key, "T1a faculty", people_keys, role_tokens, findings) + + # ---- per-session checks (+ gather per-day lanes for the non-overlap check) ---- + # Overlap only matters WITHIN a calendar day, so lanes are keyed per day, then per track. + day_lanes: dict = defaultdict(lambda: defaultdict(list)) # id(day) -> lane -> [(smin,emin,loc,choice)] + for sess, loc, day in _iter_sessions(doc): + kind = sess.get("kind") + smin, emin = _hhmm_to_min(sess.get("start")), _hhmm_to_min(sess.get("end")) + + # 1c: end >= start + if smin is not None and emin is not None and emin < smin: + findings.append(Finding("error", "T1c " + loc, f"end {sess.get('end')} is before start {sess.get('start')}")) + + # 1a: instructor resolution + for key in _collect_instructor_strings(sess): + _resolve_person(key, "T1a " + loc, people_keys, role_tokens, findings) + + # 1d: declared-track closure + sess_tracks = [] + if sess.get("track"): + sess_tracks.append(sess["track"]) + sess_tracks += list(sess.get("tracks") or []) + for t in sess_tracks: + if declared_tracks and t not in declared_tracks: + findings.append(Finding("error", "T1d " + loc, f"track '{t}' is not in the declared tracks {sorted(declared_tracks)}")) + + # 1f: TODO closure + if kind == "todo": + lvl = "error" if status != "archive" else "warning" + findings.append(Finding(lvl, "T1f " + loc, "unresolved importer TODO row: " + (sess.get("source") or "")[:160])) + + # 1b: gather lanes for non-overlap (skip exempt / shadow / untimed) + if kind in NONOVERLAP_EXEMPT_KINDS or sess.get("shadow") or smin is None or emin is None: + continue + for lk in (sess_tracks or [_ALL_LANE]): + day_lanes[id(day)][lk].append((smin, emin, loc, sess.get("choice"))) + + # ---- Tier 1b: non-overlap within each (day, track) lane ---- + # An _ALL_LANE (untracked) session conflicts with everything that day; fold it into every real lane. + for lanes in day_lanes.values(): + real_lanes = [lk for lk in lanes if lk != _ALL_LANE] + for lk in (real_lanes or [_ALL_LANE]): + items = list(lanes.get(lk, [])) + if lk != _ALL_LANE: + items += lanes.get(_ALL_LANE, []) + items.sort(key=lambda x: x[0]) + for i in range(len(items)): + for j in range(i + 1, len(items)): + a, b = items[i], items[j] + if b[0] >= a[1]: # sorted: no later item can overlap a + break + if a[3] is not None and a[3] == b[3]: # same choice => alternatives, not overlap + continue + lane_name = "all tracks" if lk == _ALL_LANE else f"track '{lk}'" + findings.append(Finding("error", "T1b " + a[2], f"overlaps ({lane_name}) with -> {b[2]}")) + + # dedupe identical findings (an _ALL_LANE pair can surface once per real lane) + seen, deduped = set(), [] + for f in findings: + sig = (f.level, f.where, f.msg) + if sig not in seen: + seen.add(sig) + deduped.append(f) + return deduped + + +def _resolve_person(key, where, people_keys, role_tokens, findings): + if key in people_keys: + return + low = key.lower() + if low in role_tokens: + if low == "tbd": + findings.append(Finding("warning", where, "instructor 'tbd' is a placeholder still to be filled")) + return + sugg = get_close_matches(low, people_keys, n=1) + hint = f" (did you mean '{sugg[0]}'?)" if sugg else "" + findings.append(Finding("error", where, f"instructor '{key}' resolves to neither a person (_data/team/{key}.yml) nor a role{hint}")) + + +def main(argv=None): + ap = argparse.ArgumentParser(description="Validate ICI3D clinic schedule data files (Tier 0 + Tier 1).") + ap.add_argument("files", nargs="+", help="schedule YAML file(s)") + here = Path(__file__).resolve().parent.parent + ap.add_argument("--schema", default=str(here / "schemas/schedule-cohort.schema.json")) + ap.add_argument("--people-dir", default=str(here / "ICI3D.github.io/_data/team")) + ap.add_argument("--roles", default=str(here / "_data/schedule/roles.yml")) + ap.add_argument("--github", action="store_true", help="also emit ::error/::warning GitHub annotations") + args = ap.parse_args(argv) + + schema = json.loads(Path(args.schema).read_text()) + people_keys = {p.stem for p in Path(args.people_dir).glob("*.yml") if p.stem != "template"} + roles_doc = yaml.safe_load(Path(args.roles).read_text()) or {} + role_tokens = {r.lower() for r in (roles_doc.get("roles") or [])} + + n_err = n_warn = 0 + for f in args.files: + p = Path(f) + findings = validate_doc(p, schema, people_keys, role_tokens) + errs = [x for x in findings if x.level == "error"] + warns = [x for x in findings if x.level == "warning"] + n_err += len(errs) + n_warn += len(warns) + status = "OK" if not errs else f"{len(errs)} ERROR(S)" + print(f"\n=== {f}: {status}{' + ' + str(len(warns)) + ' warning(s)' if warns else ''} ===") + for x in findings: + print(f" [{x.level.upper()}] {x.where}\n {x.msg}") + if args.github: + tag = "error" if x.level == "error" else "warning" + print(f"::{tag} file={f}::{x.where}: {x.msg}") + print(f"\nTOTAL: {n_err} error(s), {n_warn} warning(s) across {len(args.files)} file(s)") + return 1 if n_err else 0 + + +if __name__ == "__main__": + sys.exit(main())