Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Third-party notices

## open-spaced-repetition/ts-fsrs

FlashDay uses the upstream `ts-fsrs` package directly for memory timing:

- Repository: `open-spaced-repetition/ts-fsrs`
- Package version pinned: `5.4.2`
- Algorithm advertised by upstream: FSRS v6
- License: MIT
- Runtime package: `ts-fsrs`

FlashDay does **not** reimplement the FSRS equations. `fsrs-scheduler.mjs` is an application adapter around upstream `createEmptyCard`, `fsrs`, `Rating`, `State`, `next()` and `get_retrievability()` APIs.

The FSRS state is keyed by `Unit × Mode` (`unitId::listen|speak|read|write`), not by sentence card. Review events remain the durable history; serialized FSRS cards are a rebuildable cache. `enable_fuzz` is intentionally disabled so replaying the same append-only event history produces deterministic due dates.

## google/bespoke

This branch contains JavaScript ports/adaptations based on:
Expand Down Expand Up @@ -34,11 +48,11 @@ Behavior checks were translated from:
- `android/app/src/test/java/com/google/bespoke/DeckEngineTest.kt`
- `android/app/src/test/java/com/google/bespoke/EngineStressTest.kt`

`bespoke-engine.js` preserves upstream scheduling constants, state transitions, urgency calculation, task selection, card scoring, usage penalty and serialization behavior.
`bespoke-engine.js` remains a faithful port of upstream scheduling/state/card-scoring behavior for regression comparison. In the FlashDay hybrid runtime, its long-term urgency is no longer the authority that makes a `Unit × Mode` due; FSRS owns that boundary. Bespoke still provides the language-specific Unit/mode model, introduction preference, CardIndex, card-usage history and context/card ranking.

`bespoke-card-index.js` preserves the core `unit_id -> card_ids` indexing behavior and support for cards containing multiple `UnitTag`s. Its deterministic FlashDay importer is an adaptation and is clearly marked as such.

`bespoke-adapter.js` is intentionally thin: it converts FlashDay's local data to Bespoke units/cards and persists progress. FlashDay-specific behavior must stay out of `bespoke-engine.js`.
`bespoke-adapter.js` is the product boundary that combines the two upstream systems. FlashDay-specific hybrid policy stays there rather than being pushed into either upstream port.

Google's upstream README explicitly describes Bespoke as experimental. This branch therefore treats it as an implementation baseline with source and tests, not as scientifically validated ground truth.

Expand Down
124 changes: 103 additions & 21 deletions bespoke-adapter.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
/*
* Thin FlashDay adapter around the google/bespoke engine and CardIndex ports.
* Scheduling, mode state, card scoring and per-unit rating semantics stay in
* the ported Bespoke modules. FlashDay-specific work here is data translation and
* browser persistence only.
* FlashDay adapter around google/bespoke language/card policy plus FSRS timing.
*
* Responsibility boundary:
* - Bespoke: Unit model, four language modes, CardIndex, card ranking/usage.
* - FSRS: due/retrievability/next interval for each Unit x Mode memory.
* - FlashDay review events: durable history used to rebuild both caches.
*/
(function(root,factory){
if(typeof window==='undefined'&&typeof module==='object'&&module.exports) module.exports=factory(require('./bespoke-engine.js'),require('./bespoke-card-index.js'),require('./source-capture.js'));
else root.FlashDayBespoke=factory(root.BespokeSrs,root.BespokeCardIndex,root.FlashDaySourceCapture);
})(typeof globalThis!=='undefined'?globalThis:this,function(B,CI,SC){
if(typeof window==='undefined'&&typeof module==='object'&&module.exports) module.exports=factory(require('./bespoke-engine.js'),require('./bespoke-card-index.js'),require('./source-capture.js'),globalThis.FlashDayFsrs||null);
else root.FlashDayBespoke=factory(root.BespokeSrs,root.BespokeCardIndex,root.FlashDaySourceCapture,root.FlashDayFsrs||null);
})(typeof globalThis!=='undefined'?globalThis:this,function(B,CI,SC,F){
'use strict';
if(!B||!CI||!SC)throw new Error('BespokeSrs, BespokeCardIndex and FlashDaySourceCapture are required');

const ACTIVE_MODES=[B.Mode.LISTEN,B.Mode.SPEAK,B.Mode.READ,B.Mode.WRITE];
const VALID_DIFFICULTIES=new Set(Object.values(B.Difficulty));
const VALID_AUDIO_KINDS=new Set(['source-audio','linked-audio','browser-tts','none']);
const BESPOKE_SOURCE='google/bespoke@67b1eda5b28f7a69be20561014255cdc81110a3e';
const HYBRID_SCHEDULER='bespoke-language-policy+fsrs6';
const MODE_META={
listen:{label:'Nghe',front:'Nghe câu rồi thử nhớ nội dung.'},
speak:{label:'Nói',front:'Nói câu tiếng Anh từ câu tiếng Việt.'},
Expand Down Expand Up @@ -49,10 +53,12 @@

function buildEngine(db){return createEngine(db,{loadProgress:true});}

function schedulerSource(){return F?`${F.FSRS_SOURCE} + ${BESPOKE_SOURCE}`:BESPOKE_SOURCE;}

function saveEngine(db,engine){
db.bespokeProgress=engine.saveObject();
db.scheduler='google-bespoke-port';
db.schedulerSource='google/bespoke@67b1eda5b28f7a69be20561014255cdc81110a3e';
db.scheduler=F?HYBRID_SCHEDULER:'google-bespoke-port';
db.schedulerSource=schedulerSource();
}

function rebuildProgressFromEvents(db){
Expand All @@ -65,20 +71,76 @@
const time=Number(event.answeredAt)/1000;
for(const unitId of Array.isArray(event.unitIds)?event.unitIds:[]){
const score=Number(event.ratings?.[unitId]??0);
if(score!==1&&score!==3)continue;
if(score!==1&&score!==2&&score!==3&&score!==4)continue;
const unit=engine.unitLookup[unitId];
if(!unit)continue;
engine.rate(unit,event.mode,score,time);
}
if(event.cardId)engine.logUsage(String(event.cardId),Boolean(event.isReported),time);
}
saveEngine(db,engine);
if(F)F.rebuildDbProgress(db);
return engine;
}

function taskPairs(engine){
const out=[];
for(const unit of engine.unitsWithCards){
if(!engine.getCardsForUnit(unit.id,1).length)continue;
for(const mode of ACTIVE_MODES)out.push({unitId:unit.id,mode});
}
return out;
}

function chooseBestCard(engine,unitId,mode,nowMs){
const cards=engine.getCardsForUnit(unitId,1000);
if(!cards.length)throw new Error(`Unit ${unitId} chưa có card hợp lệ.`);
let best=cards[0],bestScore=engine.scoreCard(best,mode,nowMs/1000);
for(let i=1;i<cards.length;i++){
const score=engine.scoreCard(cards[i],mode,nowMs/1000);
if(score>bestScore){best=cards[i];bestScore=score;}
}
return best;
}

function chooseHybridTask(db,engine,nowMs){
if(!F)return null;
F.ensureProgress(db);
const tasks=taskPairs(engine);
const due=F.rankDueTasks(db,tasks,nowMs);
if(due.length)return {unitId:due[0].unitId,mode:due[0].mode,reason:'fsrs-due',memory:due[0]};

const unseen=F.newTasks(db,tasks);
if(unseen.length){
// Bespoke still gets first say over which unseen language task to introduce.
// Its long-term urgency is NOT allowed to make a future FSRS task due early.
try{
const preferred=engine.chooseTask(nowMs/1000);
const match=unseen.find(task=>task.unitId===preferred.unitId&&task.mode===preferred.mode);
if(match)return {...match,reason:'bespoke-introduction',memory:F.taskState(db,match.unitId,match.mode,nowMs)};
}catch(_e){}
const fallback=unseen[0];
return {...fallback,reason:'ordered-introduction',memory:F.taskState(db,fallback.unitId,fallback.mode,nowMs)};
}

const nextDue=F.nextDueAt(db,tasks);
if(nextDue!=null){
const waitMs=Math.max(0,nextDue-nowMs);
const minutes=Math.max(1,Math.ceil(waitMs/60000));
throw new Error(`FSRS chưa có Unit × kỹ năng nào đến hạn. Lần gần nhất sau khoảng ${minutes} phút.`);
}
throw new Error('FSRS chưa tìm thấy Unit × kỹ năng có thể học.');
}

function selectNext(db,nowMs=Date.now()){
const engine=buildEngine(db);const picked=engine.draw(nowMs/1000);
return {mode:picked.mode,unitId:picked.unitId,card:picked.card,engine};
const engine=buildEngine(db);
if(F){
const picked=chooseHybridTask(db,engine,nowMs);
const card=chooseBestCard(engine,picked.unitId,picked.mode,nowMs);
return {mode:picked.mode,unitId:picked.unitId,card,engine,memory:picked.memory,selectionReason:picked.reason};
}
const picked=engine.draw(nowMs/1000);
return {mode:picked.mode,unitId:picked.unitId,card:picked.card,engine,selectionReason:'bespoke-only'};
}

function initialRatings(card){return Object.fromEntries(B.unitIds(card).map(id=>[id,0]));}
Expand Down Expand Up @@ -106,6 +168,7 @@
engine.rate(unit,selection.mode,score,nowMs/1000);applied[unitId]=score;
}
engine.logUsage(selection.card.id,isReported,nowMs/1000);saveEngine(db,engine);
const fsrsUpdates=F?F.applyRatings(db,selection.mode,applied,nowMs):[];
const event={
id:reviewEventId(nowMs),
mode:selection.mode,cardId:selection.card.id,unitIds:B.unitIds(selection.card),ratings:applied,
Expand All @@ -117,16 +180,19 @@
recordedLocally:Boolean(response?.recordedLocally)
},
stimulus:normalizeStimulus(stimulus),
answeredAt:nowMs,scheduler:'google-bespoke-port',
upstream:'google/bespoke@67b1eda5b28f7a69be20561014255cdc81110a3e'
answeredAt:nowMs,
scheduler:F?HYBRID_SCHEDULER:'google-bespoke-port',
memoryScheduler:F?F.FSRS_SOURCE:null,
languagePolicy:BESPOKE_SOURCE,
fsrsGrades:F?Object.fromEntries(Object.entries(applied).map(([unitId,score])=>[unitId,F.ratingFromBespokeScore(score)])):{}
};
// Do not truncate the event history: scheduler state is rebuildable from this
// append-only log. If storage ever becomes a problem, archive explicitly.
// Do not truncate the event history: both scheduler caches are rebuildable
// from this append-only log. If storage becomes a problem, archive explicitly.
db.events=db.events||[];db.events.push(event);
return {event,engine};
return {event,engine,fsrsUpdates};
}

function modeStatus(state,mode){
function bespokeModeStatus(state,mode){
if(state.isMature(mode))return 'Vững';
if(state.isKnown(mode))return 'Quen';
if(state.isIntroduced(mode))return 'Đang học';
Expand All @@ -136,12 +202,28 @@

function itemStatus(db,itemId,nowMs=Date.now()){
const engine=buildEngine(db);const state=engine.ratingStates[itemId]||new B.RatingState();
return ACTIVE_MODES.map(mode=>({mode,label:MODE_META[mode].label,status:modeStatus(state,mode),urgency:state.urgency(mode,nowMs/1000),ratings:state.ratings().filter(r=>r.mode===mode).length}));
return ACTIVE_MODES.map(mode=>{
if(!F)return {mode,label:MODE_META[mode].label,status:bespokeModeStatus(state,mode),urgency:state.urgency(mode,nowMs/1000),ratings:state.ratings().filter(r=>r.mode===mode).length};
const memory=F.taskState(db,itemId,mode,nowMs);
let status='Mới';
if(!memory.isNew){
if(memory.isDue)status='Đến hạn';
else if(memory.card?.state===F.State.Learning||memory.card?.state===F.State.Relearning)status='Đang học';
else status='Đã lên lịch';
}
return {mode,label:MODE_META[mode].label,status,ratings:state.ratings().filter(r=>r.mode===mode).length,dueAt:memory.dueAt,retrievability:memory.retrievability,fsrsState:memory.card?.state??null};
});
}

function deckStats(db,nowMs=Date.now()){const engine=buildEngine(db);return engine.stats(nowMs/1000);}
function deckStats(db,nowMs=Date.now()){
const engine=buildEngine(db);
const base=engine.stats(nowMs/1000);
if(!F)return base;
const memory=F.stats(db,taskPairs(engine),nowMs);
return {waiting:memory.due,known:base.known,mature:base.mature,fsrs:memory};
}
function cardParts(card){return CI.splitIntoParts(card);}
function cardCountForUnit(db,unitId){return buildEngine(db).cardIndex.size(unitId);}

return {ACTIVE_MODES,MODE_META,normalizedDifficulty,normalizeStimulus,buildEngine,saveEngine,rebuildProgressFromEvents,selectNext,initialRatings,cycleRating,allSuccess,hasCompleteRatings,finalizeCard,itemStatus,deckStats,cardParts,cardCountForUnit,datasetCards};
return {ACTIVE_MODES,MODE_META,normalizedDifficulty,normalizeStimulus,buildEngine,saveEngine,rebuildProgressFromEvents,selectNext,initialRatings,cycleRating,allSuccess,hasCompleteRatings,finalizeCard,itemStatus,deckStats,cardParts,cardCountForUnit,datasetCards,taskPairs,chooseHybridTask,HYBRID_SCHEDULER,BESPOKE_SOURCE,hasFsrs:Boolean(F)};
});
14 changes: 9 additions & 5 deletions flashday-cloud.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
})(typeof globalThis !== 'undefined' ? globalThis : this, function() {
'use strict';

const HYBRID_SCHEDULER = 'bespoke-language-policy+fsrs6';
const HYBRID_SOURCE = 'open-spaced-repetition/ts-fsrs@v5.4.2 + google/bespoke@67b1eda5b28f7a69be20561014255cdc81110a3e';

function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
Expand Down Expand Up @@ -119,17 +122,18 @@
.sort((a, b) => Number(a.answeredAt || 0) - Number(b.answeredAt || 0));

return {
version: String(localDb.version || progressPayload.version || 'repo-driven-1'),
version: String(localDb.version || progressPayload.version || 'repo-driven-2'),
createdAt: Number(localDb.createdAt || Date.now()),
items: mergeById(remoteItems, localDb.items || []),
bespokeCards: mergeById(remoteCards, localDb.bespokeCards || []),
captures: mergeById(remoteCaptures, localDb.captures || []),
events,
// Review events are the durable history. Progress is only a cache; rebuild
// it after a merge. Preserve a legacy cache only when there is no history.
// Review events are durable history. Both scheduler objects are caches and
// must be rebuilt after a multi-device merge whenever history exists.
bespokeProgress: events.length ? null : clone(progressPayload.bespokeProgress || localDb.bespokeProgress || null),
scheduler: String(progressPayload.scheduler || localDb.scheduler || 'google-bespoke-port'),
schedulerSource: String(progressPayload.schedulerSource || localDb.schedulerSource || 'google/bespoke@67b1eda5b28f7a69be20561014255cdc81110a3e')
fsrsProgress: events.length ? null : clone(progressPayload.fsrsProgress || localDb.fsrsProgress || null),
scheduler: String(progressPayload.scheduler || localDb.scheduler || HYBRID_SCHEDULER),
schedulerSource: String(progressPayload.schedulerSource || localDb.schedulerSource || HYBRID_SOURCE)
};
}

Expand Down
26 changes: 15 additions & 11 deletions flashday-data.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
/*
* Minimal FlashDay-owned data/persistence layer for the repo-driven prototype.
* Minimal FlashDay-owned data/persistence layer for the repo-driven product.
*
* IMPORTANT: no scheduler, learner-model, evidence weighting or probe policy
* belongs in this file. Scheduling/card selection lives in the google/bespoke
* port. Source/media contracts live in source-capture/transcript-import.
* IMPORTANT: learning algorithms do not belong here. Bespoke language/card
* policy lives in its port/adapter; FSRS timing lives in fsrs-scheduler.mjs.
*/
(function(root,factory){
if(typeof window==='undefined'&&typeof module==='object'&&module.exports) module.exports=factory();
else root.FlashDayData=factory();
})(typeof globalThis!=='undefined'?globalThis:this,function(){
'use strict';

const HYBRID_SCHEDULER='bespoke-language-policy+fsrs6';
const HYBRID_SOURCE='open-spaced-repetition/ts-fsrs@v5.4.2 + google/bespoke@67b1eda5b28f7a69be20561014255cdc81110a3e';

const SEED_ITEMS=[
{id:'pick-up-order',type:'collocation',target:'pick up an order',meaning:'nhận / lấy một đơn hàng',forms:[],accepted:['pick up the order','pick up my order'],contexts:['Bạn tới quầy của nhà hàng để nhận đơn đã chuẩn bị xong.'],tags:['delivery','work'],intent:'Nhận đơn đã đặt trước.',canDo:'Tôi có thể nói với nhân viên rằng mình tới nhận đơn.',exampleSentence:'I need to pick up an order before six.',exampleTranslation:'Tôi cần nhận một đơn hàng trước sáu giờ.',origin:'curated'},
{id:'on-my-way',type:'chunk',target:"I'm on my way.",meaning:'Tôi đang trên đường tới.',forms:['I am on my way.'],accepted:["I'm on the way",'I am on the way'],contexts:['Một người đang đợi và hỏi bạn đang ở đâu.'],tags:['daily','message'],intent:'Báo cho người khác biết tôi đang di chuyển tới.',canDo:'Tôi có thể nhắn rằng tôi đang trên đường tới điểm hẹn.',exampleSentence:"I'm on my way. I'll be there in ten minutes.",exampleTranslation:'Tôi đang trên đường. Tôi sẽ tới trong mười phút.',origin:'curated'},
Expand All @@ -25,21 +27,23 @@

function createInitialDb(items=SEED_ITEMS,now=Date.now()){
return {
version:'repo-driven-1',createdAt:now,items:clone(items),events:[],captures:[],bespokeCards:[],bespokeProgress:null,
scheduler:'google-bespoke-port',schedulerSource:'google/bespoke@67b1eda5b28f7a69be20561014255cdc81110a3e'
version:'repo-driven-2',createdAt:now,items:clone(items),events:[],captures:[],bespokeCards:[],
bespokeProgress:null,fsrsProgress:null,
scheduler:HYBRID_SCHEDULER,schedulerSource:HYBRID_SOURCE
};
}

function migrateDb(raw,now=Date.now()){
if(!raw||!Array.isArray(raw.items))return createInitialDb(undefined,now);
return {
version:'repo-driven-1',createdAt:Number(raw.createdAt||now),items:clone(raw.items),
version:'repo-driven-2',createdAt:Number(raw.createdAt||now),items:clone(raw.items),
events:Array.isArray(raw.events)?clone(raw.events):[],
captures:Array.isArray(raw.captures)?clone(raw.captures):[],
bespokeCards:Array.isArray(raw.bespokeCards)?clone(raw.bespokeCards):[],
bespokeProgress:raw.bespokeProgress?clone(raw.bespokeProgress):null,
scheduler:raw.scheduler||'google-bespoke-port',
schedulerSource:raw.schedulerSource||'google/bespoke@67b1eda5b28f7a69be20561014255cdc81110a3e'
fsrsProgress:raw.fsrsProgress?clone(raw.fsrsProgress):null,
scheduler:raw.scheduler||HYBRID_SCHEDULER,
schedulerSource:raw.schedulerSource||HYBRID_SOURCE
};
}

Expand All @@ -65,11 +69,11 @@

function isPristineDb(db){
if(!db||!Array.isArray(db.items))return true;
if((db.events||[]).length||(db.captures||[]).length||(db.bespokeCards||[]).length||db.bespokeProgress)return false;
if((db.events||[]).length||(db.captures||[]).length||(db.bespokeCards||[]).length||db.bespokeProgress||db.fsrsProgress)return false;
if(db.items.length!==SEED_ITEMS.length)return false;
const expected=new Map(SEED_ITEMS.map(item=>[item.id,normalizeKey(item.target)]));
return db.items.every(item=>expected.get(item.id)===normalizeKey(item.target));
}

return {SEED_ITEMS,createInitialDb,migrateDb,addItem,isPristineDb,normalizeKey,uid,clone};
return {SEED_ITEMS,HYBRID_SCHEDULER,HYBRID_SOURCE,createInitialDb,migrateDb,addItem,isPristineDb,normalizeKey,uid,clone};
});
Loading
Loading