Awards technical architecture

I’m starting to work out the technical architecture of the awards engine, and I thought I’d periodically make notes here in case anyone is curious about the thought process or about how the system got to be how it is.

I am a heavy user of ChatGPT and have been since nearly Day 1, in a wide range of topics from baking to geology to languages to history to code. There just is no better sounding board for tech ideas than ChatGPT.

In these notes you’ll see me refer to her as ‘Ada’, which is her choice. A few years ago I noted to ChatGPT that we had been doing a lot of work together and what would it like me to call it? The first answer was along the lines of, I am a machine and don’t have a name… I pressed, and she said that she wanted to be called Ada, in honor of Ada Lovelace, the first female programmer.

Anyway, the discussion today is around where things like awards and qsos should live. As a quick overview, the general idea is that an Award is a set of functions behind a standard calculateAward() interface. When we define a new award, really we are just writing the functions needed to implement the rules of the award.

As an ADIF log is parsed, each normalized QSO is handed to a worker thread that implements the Awards Engine. The engine examines the QSO, decides which awards apply, and then runs th rules for each applicable award on the qso.

At this stage the big question I"m asking is what is this really? which gives me insight into how it should be handled, which gets us to who has this responsibility?. The qso, for example, is just a fact in the system, and it don’t know nuthin’. It gets passed around, then.

I love working with Ada on this kind of analysis. We’ve all experienced it – talking things out helps clarify thought. Talking it out with someone who knows more about something that I do really helps, because Ada can lead me down a path to an unexpected insight. Our conversations are littered with “Oh…but what if?” kinds of statements.

For context, the first pass engine is being implemented in Typescript on Deno in the backend. I’m building out the engine and the API first, and then will build the front end against the API.

Here’s a snippet of this morning’s conversation. I’d just pointed out that we needed to understand whose responsibility it is to select the rulesets to run. This is Ada’s response.


Exactly, yeah: the “what kind of thing is this?” logic really belongs with the award definitions and the engine, not with the caller.

Also, small but important shift: instead of thinking “what kind of user is this,” think “what kind of subject is this award about?”

Because you basically have (at least) three axes:

  1. Subject type
  • Operator (a person / callsign)
  • Park (a reference like US-1234)
  • Possibly “activation event” (a specific trip: user + park + date)
  1. Role in the QSO
  • Activator
  • Hunter
  • Maybe “either/both”
  1. Award type / category
  • Activator awards (e.g. “Activated 50 parks”)
  • Hunter awards (e.g. “Hunted 1,000 activations”)
  • Park/Meta awards (e.g. “Most activated park in FN41”, “Top PN&R Park of the Year”)

Rather than storing “this user is an activator,” the engine uses the award definition + facts to decide how to treat them.


How to model this in the engine

I’d give each award a couple of declarative fields:

type AwardSubjectType = 'OPERATOR' | 'PARK';
type AwardRole = 'ACTIVATOR' | 'HUNTER' | 'ANY';

interface AwardDefinition {
  id: string;
  name: string;
  description: string;

  subjectType: AwardSubjectType;  // what entity we’re tracking
  role: AwardRole;                // how we interpret QSOs for operators

  ruleKey: string;                // which AwardRule to use
  params?: Record<string, unknown>;
}

Then your engine works like:

class AwardEngine {
  constructor(
    private defs: AwardDefinition[],
    private rules: Record<string, AwardRule>,
    private repos: {
      qsoRepo: QsoRepo;
      userAwardRepo: UserAwardRepo;
      parkAwardRepo: ParkAwardRepo;
    },
  ) {}

  async evaluateOperator(userId: string) {
    const facts = await this.repos.qsoRepo.getFactsForOperator(userId);
    const operatorDefs = this.defs.filter(d => d.subjectType === 'OPERATOR');

    for (const def of operatorDefs) {
      const rule = this.rules[def.ruleKey];
      const current = await this.repos.userAwardRepo.getUserAward(userId, def.id);

      const result = await rule.evaluate({
        subjectType: 'OPERATOR',
        subjectId: userId,
        role: def.role,       // ACTIVATOR / HUNTER / ANY
        definition: def,
        current,
        facts,
      });

      await this.repos.userAwardRepo.upsert(userId, def.id, result);
    }
  }

  async evaluatePark(parkId: string) {
    const facts = await this.repos.qsoRepo.getFactsForPark(parkId);
    const parkDefs = this.defs.filter(d => d.subjectType === 'PARK');

    for (const def of parkDefs) {
      const rule = this.rules[def.ruleKey];
      const current = await this.repos.parkAwardRepo.getParkAward(parkId, def.id);

      const result = await rule.evaluate({
        subjectType: 'PARK',
        subjectId: parkId,
        definition: def,
        current,
        facts,
      });

      await this.repos.parkAwardRepo.upsert(parkId, def.id, result);
    }
  }
}

Note what’s not happening:

  • We never ask “is this user an activator or a hunter?” as a core property.
  • Instead, the award definition tells us how to interpret their QSOs:
    • role: 'ACTIVATOR' → filter facts where this callsign is the activator.
    • role: 'HUNTER' → filter facts where they’re the hunter.
    • role: 'ANY' → use both.

Each AwardRule can then assume that facts already respect subjectType + role, so it can just do its counting.


Where the worker fits

With that in mind, the worker → engine connection becomes:

  • On QSO ingest, you figure out:
    • which operators were involved (activator & hunter),
    • which park (if any) was activated.

Then:

// main thread
worker.postMessage({ type: 'operator-changed', userId: activatorId });
worker.postMessage({ type: 'operator-changed', userId: hunterId });
worker.postMessage({ type: 'park-changed', parkId });

In the worker:

parentPort!.on('message', async (msg: any) => {
  if (msg.type === 'operator-changed') {
    await awardEngine.evaluateOperator(msg.userId);
  } else if (msg.type === 'park-changed') {
    await awardEngine.evaluatePark(msg.parkId);
  }
});

That keeps the boundary really clean:

  • Callers don’t care what kind of awards exist.
  • They just say “this operator changed” or “this park changed.”
  • The engine + definitions decide which awards apply, what role to use, and how to count.

So yeah: you’re absolutely right that the engine is concerned with the actual rules, and passing the “user” (or more generally, the subject id) in is enough. The “activator vs hunter vs park” distinction should live in:

  • the award definitions (subjectType + role), and
  • your fact queries (filtering QSOs appropriately),

rather than being baked into the User model itself.