|
| 1 | +# tuni-folk-gemini |
| 2 | + |
| 3 | +**Reinforcement-learning fine-tuning of Gemini 3.5 Flash to compose Tunisian folk poetry |
| 4 | +(الشعر الشعبي التونسي / الملحون).** |
| 5 | + |
| 6 | +Tunisian folk poetry is an unusually good RLFT target. Its metrical system is *not* |
| 7 | +Khalilian — Muhammad al-Marzuqi's argument, adopted by Muhyi al-Din Khrayyef and by the |
| 8 | +editorial committee of the national corpus, is that the Tunisian dialect has `asbab` and |
| 9 | +`awtad` but no `fawasil`, so classical `buhur` cannot be applied at all: |
| 10 | + |
| 11 | +> «الشعر الشعبي الحديث لا يمكن أن نطبّق عليه البحور القديمة… ولا يمكن أن نضبط موازينه |
| 12 | +> إلا بواسطة الإيقاع» — المرزوقي، *الأدب الشعبي*، ص 83 |
| 13 | +
|
| 14 | +What *does* hold a poem together is a **rhyme topology** that differs per form and is fully |
| 15 | +decidable by a program. That gives reinforcement learning something rare: an objective, |
| 16 | +cheap, hard-to-game structural signal, on a task where supervised fine-tuning could only |
| 17 | +teach the model to imitate 843 specific poems. |
| 18 | + |
| 19 | +--- |
| 20 | + |
| 21 | +## Approach in one page |
| 22 | + |
| 23 | +RLFT is worth using when you can **score** an output better than you can **demonstrate** |
| 24 | +one. This repository is built around making that true for a creative task: |
| 25 | + |
| 26 | +1. **The corpus supplies tasks, not targets.** RLFT examples carry no reference answer — |
| 27 | + only a prompt and a `references` map the reward reads. The corpus is used to derive |
| 28 | + realistic task specifications (form + `gharaḍ`, optionally a required opening |
| 29 | + hemistich) and a fingerprint of the source poem. It is never a target output. |
| 30 | + |
| 31 | +2. **The reward is mostly computation, partly judgement.** A deterministic scorer |
| 32 | + (weight 0.7) measures what is decidable — rhyme topology, dialect fidelity, novelty, |
| 33 | + repetition. A Gemini judge (weight 0.3) covers only the residue that code cannot see. |
| 34 | + They stay separate so the two curves can be watched diverging. |
| 35 | + |
| 36 | +3. **The adversary is the policy itself.** Every component is calibrated against 520 |
| 37 | + base-model attempts at the same prompts — never line-shuffled or cross-form text, which |
| 38 | + any metric separates trivially. |
| 39 | + |
| 40 | +4. **Components have three different jobs.** One discriminates, two are saturating |
| 41 | + constraints, two are guards. Holding all five to one criterion is a category error, and |
| 42 | + is the single most important thing to understand about this reward — see below. |
| 43 | + |
| 44 | +5. **The task is known to be satisfiable.** Only ~41% of corpus poems labelled `malzuma` |
| 45 | + actually exhibit the `ruju'` that defines the form. Prompts are drawn only from poems |
| 46 | + that pass their own validator, so the policy is never asked for a structure most |
| 47 | + genuine examples fail. |
| 48 | + |
| 49 | +6. **Offline measurement and the training reward are the same code.** The Cloud Run |
| 50 | + service imports the same installed package the unit tests exercise. |
| 51 | + |
| 52 | +### Why some components score *below* chance, on purpose |
| 53 | + |
| 54 | +The base model **out-regularises the tradition**. Asked for `aaaB` quatrains it emits a |
| 55 | +textbook rhyme scheme; the oral corpus is irregular, with transmission noise and uneven |
| 56 | +strophes. Measured against 520 base-model generations: |
| 57 | + |
| 58 | +| Component | AUC vs base | Role | How it is verified | |
| 59 | +|---|---:|---|---| |
| 60 | +| `tunisianity` | **1.000** | discriminator | AUC ≥ 0.60 | |
| 61 | +| `form` | 0.320 | constraint | scrambling a real poem must lower it (0.923 → 0.513) | |
| 62 | +| `novelty` | 0.500 | guard | submitting the source verbatim must collapse it (→ 0.000) | |
| 63 | +| `non_repetition` | 0.397 | guard | a repeated line must collapse it (→ 0.086) | |
| 64 | +| `meter` | 0.268 | diagnostic | weight 0; reported, not optimised | |
| 65 | + |
| 66 | +A monotonic structural reward would push the policy *away* from the target register, so |
| 67 | +`form` and `meter` **saturate** at per-form corpus medians: full credit for reaching the |
| 68 | +tradition's level, nothing for exceeding it. Deleting them is not the alternative — nothing |
| 69 | +would then require the requested wazn. |
| 70 | + |
| 71 | +Composite held-out AUC: **0.9978** vs base darija, **0.9978** vs hard few-shot negatives, |
| 72 | +**0.9914** vs MSA (an axis it was never trained on). |
| 73 | + |
| 74 | +--- |
| 75 | + |
| 76 | +## The four forms |
| 77 | + |
| 78 | +Every structural component derives from the rhyme topology of the four `usul`: |
| 79 | + |
| 80 | +| Form | Topology | Defining constraint | |
| 81 | +|---|---|---| |
| 82 | +| **القسيم** `qasim` | `a B / a B / a B …` | two parallel monorhymes, no `tali'`, no return | |
| 83 | +| **الملزومة** `malzuma` | `AA ‖ bbb A / ccc A …` | every strophe **returns** (`ruju'`) to the `tali'` rhyme — this is what *malzūma* means | |
| 84 | +| **الموقف** `mawqif` | `aaaB / cccB …` | fourth `ghusn` holds one rhyme fixed across the whole poem | |
| 85 | +| **المسدّسة** `musaddas` | `AAA ‖ bbbb AA …` | sextets whose last two `aghsan` return to the `tali'` | |
| 86 | + |
| 87 | +Implemented in [`src/tunifolk/prosody/forms.py`](src/tunifolk/prosody/forms.py). |
| 88 | + |
| 89 | +--- |
| 90 | + |
| 91 | +## Current run |
| 92 | + |
| 93 | +| | | |
| 94 | +|---|---| |
| 95 | +| Job | `tunifolk-rlft-v5` · `projects/808513141082/locations/us-central1/tuningJobs/5782572726988308480` | |
| 96 | +| Base model | `gemini-3.5-flash` | |
| 97 | +| Training / validation | 843 / 174 prompts, stratified by form | |
| 98 | +| Adversary | 520 base-model darija poems (300 zero-shot + 220 few-shot) + 55 MSA | |
| 99 | +| Reward | composite: 0.7 × deterministic structural scorer + 0.3 × `gemini-3.5-flash` judge | |
| 100 | +| Total steps | 156 = (843 // 32) × 6 epochs | |
| 101 | + |
| 102 | +`make status` reports the live step and reward curves. See |
| 103 | +[`docs/06-results.md`](docs/06-results.md). |
| 104 | + |
| 105 | +--- |
| 106 | + |
| 107 | +## Layout |
| 108 | + |
| 109 | +``` |
| 110 | +src/tunifolk/ |
| 111 | +├── prosody/ the measurable core — all of it unit-tested |
| 112 | +│ ├── normalize.py orthographic normalisation (never "corrects" dialect to MSA) |
| 113 | +│ ├── rhyme.py rawiyy extraction + rhyme agreement |
| 114 | +│ ├── forms.py the four usul as structural validators |
| 115 | +│ ├── meter.py positional length regularity (a prior, not a scansion) |
| 116 | +│ ├── tunisianity.py contrastive char-4gram LLR vs the base model |
| 117 | +│ └── novelty.py anti-plagiarism fingerprints (hash-sampled) |
| 118 | +├── rewards/ |
| 119 | +│ ├── structural.py the single scoring entrypoint (tests == training) |
| 120 | +│ ├── service.py Cloud Run reward server |
| 121 | +│ └── autorater.py Gemini-as-judge config + composite builder |
| 122 | +├── data/ corpus loader, RLFT dataset builder |
| 123 | +└── tuning/client.py typed v1beta1 tuningJobs client, incl. metrics reads |
| 124 | +``` |
| 125 | + |
| 126 | +--- |
| 127 | + |
| 128 | +## Reproducing |
| 129 | + |
| 130 | +```bash |
| 131 | +make venv # dev environment |
| 132 | +make test # 71 unit tests |
| 133 | +make negatives CORPUS=/path/classified.json # generate the adversary |
| 134 | +make tunisianity CORPUS=... # fit + hold-out validate the discriminator |
| 135 | +make acceptance CORPUS=... # GATE: reward must beat the base model |
| 136 | +make dataset CORPUS=... # build train/validation JSONL |
| 137 | +make upload # push to GCS |
| 138 | +make deploy # deploy the Cloud Run reward scorer |
| 139 | +make validate # validate the reward via the tuning API |
| 140 | +make launch # acceptance gate, then create the job |
| 141 | +make status # live step, curves, health, checkpoints |
| 142 | +``` |
| 143 | + |
| 144 | +`make validate` is not optional in spirit: `validateReinforcementTuningReward` catches a |
| 145 | +broken scorer in seconds, and >80% errored reward invocations aborts a whole run. |
| 146 | + |
| 147 | +### Prerequisites |
| 148 | + |
| 149 | +- A GCP project with `aiplatform`, `run`, `cloudbuild`, `artifactregistry` enabled. |
| 150 | +- `roles/run.invoker` for `service-<PROJECT_NUMBER>@gcp-sa-vertex-tune.iam.gserviceaccount.com` |
| 151 | + on the reward service — the tuning agent is the caller. |
| 152 | +- The corpus `classified.json`. **Not vendored** — see |
| 153 | + [`docs/02-dataset-design.md`](docs/02-dataset-design.md) for provenance. |
| 154 | + |
| 155 | +--- |
| 156 | + |
| 157 | +## Documentation |
| 158 | + |
| 159 | +| | | |
| 160 | +|---|---| |
| 161 | +| [01 — Background](docs/01-background.md) | the tradition, the forms, the sources | |
| 162 | +| [02 — Dataset design](docs/02-dataset-design.md) | prompt construction, stratification, provenance | |
| 163 | +| [03 — Reward design](docs/03-reward-design.md) | **each component, its role, and what the reward cannot see** | |
| 164 | +| [05 — Running a job](docs/05-running-a-tuning-job.md) | hyperparameters, monitoring, the API's sharp edges | |
| 165 | +| [06 — Results](docs/06-results.md) | protocol, pre-registered failure conditions, telemetry | |
| 166 | + |
| 167 | +## What ships |
| 168 | + |
| 169 | +The RLFT datasets **are** committed: `artifacts/dataset/train.jsonl` and |
| 170 | +`validation.jsonl` are the exact bytes uploaded to GCS and consumed by the tuning job, so a |
| 171 | +run is reproducible from this repository alone. So are the negatives, the fitted |
| 172 | +discriminator and the reward-gate reports. |
| 173 | + |
| 174 | +No corpus text is committed. The corpus is supplied at runtime by path |
| 175 | +(`CORPUS=/path/to/classified.json`) and lives outside this repository. The prompts carry |
| 176 | +only a first hemistich and a hash fingerprint of their source poem, never the poem. |
| 177 | + |
| 178 | +The one derived artifact that ships is a 300 KB contrastive n-gram model. Chaining its |
| 179 | +4-grams back into running text yields gibberish, so poems are not extractable from it; |
| 180 | +20/26 of a given line's 4-grams are present, which makes it a membership oracle rather than |
| 181 | +an extraction oracle. |
| 182 | + |
| 183 | +## Licence |
| 184 | + |
| 185 | +Apache-2.0 for the code in this repository. |
0 commit comments