UNPKG

@graperank/calculator

Version:

The GrapeRank Calculator module, calculates scorecards from input ratings.

391 lines (335 loc) 13.7 kB
import type { CalculatorParams, CalculatorSums, elemId, protocol, Rating, RatingsList, Scorecard, ScorecardData, ScorecardInterpretation, CalculatorIterationStatus, ScorecardsEntry, userId } from "@graperank/util/types" import { DEBUGTARGET } from "@graperank/util" // var params : Required<CalculatorParams> export class Calculator { constructor( readonly observer : userId, readonly ratings : RatingsList, params? : Partial<CalculatorParams>, private updateStatus? : (newstatus : CalculatorIterationStatus) => Promise<void>, private updateComplete? : () => Promise<void> ){ if(params) this.params = {...this.params, ...params} } readonly params : Required<CalculatorParams> = { // incrementally decrease influence weight attenuation : .5, // factor for calculating confidence // MUST be bellow 1 or confidence will ALWAYS be 0 // CAUTION : too high (eg:.7) and users beyond a certain DOS (eg:2) will always have a score of zero rigor : .5, // minimum score ABOVE WHICH scorecard will be included in output minscore : 0, // max difference between calculator iterations // ZERO == most precise precision : 0.00001, // devmode if off by default devmode : false } private calculators : Map<elemId,ScorecardCalculator> = new Map() private _stopped : boolean = false stop(){ this._stopped = true } /** * Calculate new scorecards from interpreted ratings and input scorecards */ async calculate () : Promise<ScorecardsEntry[]> { console.log("GrapeRank : Calculator : instantiated with ",this.ratings.length," ratings and params : ", this.params) // setup // STEP A : initialize ratee scorecard // Retrieve or create a ScorecardCalculator for each ratee in ratings for(let r in this.ratings){ if(!this.ratings[r]) continue let ratee = this.ratings[r].ratee let rater = this.ratings[r].rater if(ratee && !this.calculators.get(ratee)){ // TODO get scores for each rater from worldview input cards // let ratercard = getInputScorecard(rater as string) let calculator = new ScorecardCalculator( this.observer, ratee , this.params) if(calculator) this.calculators.set(ratee, calculator) } } console.log("GrapeRank : Calculator : setup with ",this.calculators.size," calculators.") await this.iterate() if(this.updateComplete) await this.updateComplete() return this.scorecards } // returns number of scorecards calculated private async iterate() : Promise<number | undefined> { let calculating : number = 0 let calculated : number = 0 let uncalculated : string[] let notcalculatedwarning : number = 0 let prevcalculating = 0 let prevcalculated = 0 let iteration = 0 let iterationscores : number[] = [] let iterationstatus : CalculatorIterationStatus while(calculated < this.calculators.size){ if(this._stopped) return undefined iteration ++ prevcalculating = calculating prevcalculated = calculated calculating = 0 calculated = 0 uncalculated = [] iterationstatus = {} console.log("------------ BEGIN ITERATION : ", iteration, " --------------------") // STEP B : calculate sums // Add rater's rating to the sum of weights & products for the ratee scorecard for(let r in this.ratings){ if(!this.ratings[r]) continue let calculator = this.calculators.get(this.ratings[r].ratee) let raterscore = this.calculators.get(this.ratings[r].rater as string)?.score if(calculator) { calculator.sum( this.ratings[r], raterscore) } } // STEP C : calculate influence // calculate final influence and confidence for each ratee scorecard // call calculate again if calculation is NOT complete this.calculators.forEach( (calculator, rater) => { var dos = calculator.dos || 0 iterationstatus[dos] = iterationstatus[dos] || { calculated : 0, uncalculated : 0, average : 0 } if( !calculator.calculated ){ calculator.calculate() calculating ++ } if(calculator.calculated){ calculated ++ // add to dos status for calculated iterationstatus[dos].calculated ++ // DOS average is SUM of all calculated scores UNTIL converted to an average iterationstatus[dos].average += calculator.score }else{ uncalculated.push(rater) // add to dos status for uncalculated iterationstatus[dos].uncalculated ++ } }) // calculate averages scores for each DOS status for(var dos in iterationstatus){ iterationstatus[dos].average = iterationstatus[dos].average / iterationstatus[dos].calculated } if(this.updateStatus) await this.updateStatus({...iterationstatus}) // LOG iteration iterationscores = logScoresForIteration(this.calculators) console.log("TOTAL number scorecards : ", this.calculators.size ) console.log("TOTAL scorecards calculating this iteration : ", calculating) console.log("TOTAL scorecards calculated : ", calculated) // halt iterator if needed if( uncalculated.length ){ if(calculated == prevcalculated && calculating == prevcalculating ){ notcalculatedwarning ++ console.log("WARNING ",notcalculatedwarning," : scores did not change for ", calculating," scorecards in calculate()") if(notcalculatedwarning > 4) { console.log("HALTING iterator : due to unchanging scores for the following raters : ", uncalculated) calculated = this.calculators.size } } if(iteration > 100){ console.log("HALTING iterator : exeded MAX 100 iterations in calculate() ") calculated = this.calculators.size } } console.log("------------ END ITERATION : ", iteration, " --------------------") } return calculated } get scorecards() : ScorecardsEntry[] { let scorecards : [elemId, Required<ScorecardData>][] = [] this.calculators.forEach((calculator) => { if(calculator.output) scorecards.push(calculator.output) }) // sort first : scorecards with higher scores and most ratings return scorecards.sort((a ,b )=>{ return a[1].score - b[1].score || a[1].interpretersums['nostr-follows']?.numRatings - b[1].interpretersums['nostr-follows']?.numRatings }) } } const zerosums : CalculatorSums = { weights : 0, products : 0 } /** * Calculates a single scorecard for a given subject (ratee) */ class ScorecardCalculator { get output() : [elemId, Required<ScorecardData>] | undefined { if(!this.calculated || this._data.score < this.params.minscore) return undefined return [ this._subject, this._data ] } // get scorecard() : Required<Scorecard> | undefined { // // if(!this.calculated) return undefined // return { // ...this.keys, // subject : this._subject, // ...this._data // } // } // sum() can only be run as many times as we have ratings // get summed(){ return this._sumcount < ratings.length ? false : true } get calculated(){ return this._calculated ? true : false } get dos() : number | undefined { // TODO interpreter should designate a protocol that determines DOS // using 'nostr-follows' for now return this._meta.get('nostr-follows')?.dos || 0 } get score() : number { return this._data?.score || 0 } // constructor(subject : elemId) // constructor(scorecard : Scorecard) constructor( readonly observer : userId, input : elemId | Scorecard, private params : Required<CalculatorParams> ){ // input is subject of new scorecard this._subject = typeof input == 'string' ? input : input.subject as string } // STEP B : calculate sums // calculate sum of weights & sum of products sum( rating : Rating, raterscore ? : number){ // // do nothing if ratercard.subject does not match rating.rater // if(ratercard && ratercard.subject != rating.rater){ // console.log("GrapeRank : ScorecardCalculator : WARNING ratercard.subject does not match rating.rater") // return // } // ALWAYS run calculater ... to assure score convergence for "more distant" dos // if(this.calculated) { // return // } // determine rater influence let influence = rating.rater == this.observer ? 1 : raterscore || 0 let weight = influence * rating.confidence; // no attenuation for observer if (rating.rater != this.observer) weight = weight * (this.params.attenuation); // add to sums this._sums.weights += weight this._sums.products += weight * rating.score // get the metadata entry for this protocol let protocolmeta = this._meta.get(rating.protocol) // create new metadata entry for this protocol, using existing values as available protocolmeta = { // dos = the minimum nonzero iteration number for ratings used to calculate this scorecard dos : rating.dos && protocolmeta?.dos && rating.dos < protocolmeta.dos ? rating.dos : protocolmeta?.dos || rating.dos, // weighted = weighted sum of protocol ratings calculated in this scorecard weighted : weight + (protocolmeta?.weighted || 0), // numRatings = number of protocol ratings for this subject numRatings : 1 + (protocolmeta?.numRatings || 0), // numRatedBy = number of protocol ratings for observer by this subject numRatedBy : (rating.ratee == this.observer ? 1 : 0) + (protocolmeta?.numRatedBy || 0) } // assure that the metadata entry is updated for this protocol, in case it was undefined before. this._meta.set(rating.protocol, protocolmeta) // DEBUG if(this._subject == DEBUGTARGET){ console.log('DEBUGTARGET : calculator._sums for target : ', this._sums) console.log('DEBUGTARGET : calculator._meta for target : ', this._meta) } } // STEP C : calculate influence // returns true if score was updated calculate() : boolean { if(this._subject == DEBUGTARGET){ console.log('DEBUGTARGET : caling calculator.calculate() for target : ') } // ALWAYS run calculater ... to assure score convergence for "more distant" dos // if(this.calculated) return true // calculate score let confidence = 0 let score = 0 let interpretersums : Record<protocol,ScorecardInterpretation> = {} // convert metadata map to pojo this._meta.forEach((scorecardmeta,protocol) => interpretersums[protocol] = scorecardmeta ) // If weights == 0 then confidence and score will also be 0 if(this._sums.weights > 0){ // STEP D : calculate confidence confidence = this.confidence score = this._average * confidence } // determine if calculator iterations are complete based on calculator.precision // ONLY after scores have been calculated at least ONCE (if `this._claculated` has been set) this._calculated = this._calculated === undefined ? false : Math.abs(score - this._data.score) <= this.params.precision ? true : false // zero the sums this._sums = {...zerosums}; // output the scorecard this._data = { confidence, score, interpretersums } return this.calculated } private _calculated : boolean | undefined private _subject : elemId private _data : Required<ScorecardData> private _meta : Map<protocol, ScorecardInterpretation> = new Map() // TODO refactor this._sums as this._input in the format of scorecard.input private _sums : CalculatorSums = {...zerosums} private get _average(){ let average = 0 try{ average = this._sums.products / this._sums.weights }catch(e){} return average <= 0 ? 0 : average } // STEP D : calculate confidence private get confidence(){ // TODO get default rigor let rigor = this.params.rigor !== undefined ? this.params.rigor : .25 const rigority = -Math.log(rigor) const fooB = -this._sums.weights * rigority const fooA = Math.exp(fooB) const certainty = 1 - fooA return certainty.toPrecision(4) as unknown as number // FIXME } } // LOG iteration function logScoresForIteration(calculators : Map<elemId,ScorecardCalculator>) : number[] { let scorecards : ScorecardData[] = [] let increment = .1 let scores : number[] let v = "", ov = "" calculators.forEach((calculator) => { if(calculator.output) scorecards.push( calculator.output[1]) }) scores = countScorecardsByScore(scorecards, increment) // console.log("scores counted", scores) for(let i in scores){ ov = v || "0" v = ((i as unknown as number) * increment).toPrecision(2) console.log("number of cards having scores from "+ ov +" to " +v+ " = ", scores[i]) } return scores } function countScorecardsByScore(scorecards : ScorecardData[], increment : number ) : number[] { let grouped = groupScorecardsByScore(scorecards,increment) let count : number[] = [] let index = 0 for(let g in grouped){ count[index] = grouped[g].length index ++ } return count } function groupScorecardsByScore(scorecards : ScorecardData[], increment : number ) : ScorecardData[][] { let group : ScorecardData[][] = [] for(let s in scorecards){ let card = scorecards[s] if(card?.score != undefined){ let groupid = Math.floor(card.score / increment) if(!group[groupid]) group[groupid] = [] group[groupid].push(card) } } return group }