1.4 TopKit: Links - JulTob/Tag_Oriented_Programming GitHub Wiki

The Links Guide

Things that are true of two: roads, rails, ferries, bookings, lessons.

The Guide shows how one Agent gets its Tags. A Tag says one thing about one Agent: Ruth is a Wizard. But many facts are about two: the road between two cities, a guest's booking at a hotel, a teacher and a student. A Link is a Tag of an Agent's own that holds those facts. This guide shows the main things you will use Links for, one small section each. Every block below runs, in order, with nothing installed but TopKit.

The wiki page on Links teaches how they work. This guide is about what they are good for.

Five words, so you never have to guess:

Word Meaning In this guide
Relation The template you write inside a Tag, marked @Link. Geography.Road
Link The Tag each Agent builds from it: one per Agent. alicante.Road
Contact Someone an Agent is linked to. marbella, in alicante.Road
Pair One Agent and one Contact. The facts between them live here. alicante.Road[marbella]
Seats Every function in a Relation takes two: the Agent, then the Contact. def Describe(city, other)

The setting is a small country. A City is plain Python, and TOP never asks you to change it.

import gc

from TopKit import (
        Imprint, Link, Post, Pre, Public, Record, Report, Rip, Secret, Tag,
        TagPreconditionError, TagResolutionError,
        )


class City:
    """The host: ordinary Python."""

    def __init__(self, name):
        self.name = name
        self.sea_calm = True

    def __repr__(self):
        return self.name


class Geography(Tag):

    @Link
    class Road(Tag):                                  # a Relation: the template

        @Record
        def distance(city, other, *, distance=None):  # km
            return distance

        @Record
        def speed_limit(city, other, *, speed_limit=None):   # km/h
            return speed_limit

        @Record
        def travel_time(city, other, *, distance=None, speed_limit=None):
            return round(distance / speed_limit * 60)        # minutes

        def Describe(city, other):
            road = city.Road[other]
            return (f"{city.name} to {other.name}: {road.distance} km,"
                    f" limit {road.speed_limit}, about {road.travel_time} min")

    @Link
    class Motorway(Road):                             # a Road with one more fact

        @Record
        def toll(city, other, *, toll=0):
            return toll


alicante, marbella, murcia, valencia, madrid = (
        City("Alicante"), City("Marbella"), City("Murcia"),
        City("Valencia"), City("Madrid"),
        )

for city in (alicante, marbella, murcia, valencia, madrid):
    Geography(city)                                   # each city builds its own Road

1. Facts about the link itself

You have two cities and you want to say how long the road between them takes. The time does not belong to Alicante, or to Marbella. It belongs to the road. That is a Pair, and its Records are read like any other:

alicante.Road(marbella, distance=480, speed_limit=120)

road = alicante.Road[marbella]

assert road.distance == 480
assert road.speed_limit == 120
assert road.travel_time == 240                  # worked out when the road was made
assert alicante.Road[marbella].travel_time == 240

Three things to know:

  • Write alicante.Road[marbella], with the city object. Road is each city's own: alicante.Road is Alicante's Link. The class Geography.Road is only the template, and asking it anything is refused.
  • Nothing is written on Marbella. The facts are on the Pair. Marbella is a Contact, and she carries no record of the road at all.
  • A Record is a stored fact. It is worked out once, when the road is made. You may change it later, and nothing else changes with it:
road.travel_time = 255                          # traffic today: an estimate you update
assert road.travel_time == 255
assert road.distance == 480                     # the other facts did not move

For a value that must always follow the others, write a function. A function on the Pair runs with the two cities in its two seats:

assert road.Describe() == "Alicante to Marbella: 480 km, limit 120, about 255 min"

2. Asking who is connected to whom

A Link is a Tag, so everything you can ask a Tag you can ask a Link, of one city's Link at a time:

alicante.Road(murcia, distance=70, speed_limit=100)
alicante.Road(valencia, distance=170, speed_limit=120)
valencia.Road(madrid, distance=350, speed_limit=120)
murcia.Road(marbella, distance=390, speed_limit=120)

assert murcia in alicante.Road                  # is there a road from Alicante to Murcia?
assert madrid not in alicante.Road              # not directly
assert len(alicante.Road[:]) == 3               # how many roads leave Alicante
assert set(alicante.Road) == {marbella, murcia, valencia}   # walk them

Two points follow, and both surprise people at first.

The question needs a city. murcia in Geography.Road is not a question. Only murcia in alicante.Road is, because every city has its own roads. Ask about a city's Link, never about the name alone.

A road has a direction. alicante.Road holds the roads that leave Alicante. Nothing public on Marbella says that a road reaches her. To find the cities that have a road to Madrid, walk the cities you have and ask each one:

assert [c for c in Geography[:] if madrid in c.Road] == [valencia]
assert [c for c in Geography[:] if alicante in c.Road] == []   # no road leads back

3. Routes: a walk over links

The most common use of a graph is to find a way through it. A route is a walk: from each city, follow the roads that leave it. Each step is one line of ordinary Python:

def roads_from(city):
    return list(city.Road)


def fewest_stops(start, goal):
    """The shortest list of cities from start to goal, or None."""
    seen = {start}
    paths = [start](/JulTob/Tag_Oriented_Programming/wiki/start)
    while paths:
        path = paths.pop(0)
        here = path[-1]
        if here is goal:
            return path
        for next_city in roads_from(here):
            if next_city not in seen:
                seen.add(next_city)
                paths.append(path + [next_city])
    return None


def minutes(path):
    steps = zip(path, path[1:])
    return sum(a.Road[b].travel_time for a, b in steps)


route = fewest_stops(alicante, madrid)

assert route == [alicante, valencia, madrid]
assert minutes(route) == 85 + 175               # the facts on each road, added up
assert fewest_stops(madrid, alicante) is None   # the roads are one-way

The route function never mentions a Tag. It only asks each city's Link who is next, and reads the time off each road. That is the whole use: the Link is the graph, and the Pair is the edge with its facts on it.

Fewest stops is not the same as fastest. Alicante has a direct road to Marbella, but the way through Murcia is quicker, and the facts to prove it are already on the Pairs:

direct = [alicante, marbella]
through_murcia = [alicante, murcia, marbella]

assert fewest_stops(alicante, marbella) == direct
assert minutes(direct) == 255                   # the estimate we updated in section 1
assert minutes(through_murcia) == 42 + 195      # 237: faster

To search by time instead of by stops, keep the same function and sort the paths by minutes. The Links do not change.



4. Two-way links: say it once, in plain sight

A Link goes one way. A railway runs both ways, so make two Links, and let the first one build the second. An @Imprint runs after a link is made. It links the other city back, with the same facts:

class Railway(Tag):

    @Link
    class Rail(Tag):

        @Record
        def minutes(city, other, *, minutes=None):
            return minutes

        @Imprint
        def Both_Ways(city, other):
            Railway(other)                              # the other city needs a Rail too
            other.Rail(city, minutes=city.Rail[other].minutes)


Railway(alicante)
alicante.Rail(murcia, minutes=55)

assert alicante.Rail[murcia].minutes == 55
assert murcia.Rail[alicante].minutes == 55              # the way back exists
assert alicante.Rail[murcia] is not murcia.Rail[alicante]   # two Pairs, two roads

Linking a city that is already linked does nothing, so the echo stops after one round. And because there are two Pairs, each direction may have its own facts. Give the way back its own number if the facts differ.

A city can hold several Links at once. Alicante now has a Road and a Rail, each with its own cities and its own facts. They do not mix.



4. Two-way links: say it once, in plain sight

A Link goes one way. A railway runs both ways, so make two Links, and let the first one build the second. An @Imprint runs after a link is made. It links the other city back, with the same facts:

class Railway(Tag):

    @Link
    class Rail(Tag):

        @Record
        def minutes(city, other, *, minutes=None):
            return minutes

        @Imprint
        def Both_Ways(city, other):
            Railway(other)                              # the other city needs a Rail too
            other.Rail(city, minutes=city.Rail[other].minutes)


Railway(alicante)
alicante.Rail(murcia, minutes=55)

assert alicante.Rail[murcia].minutes == 55
assert murcia.Rail[alicante].minutes == 55              # the way back exists
assert alicante.Rail[murcia] is not murcia.Rail[alicante]   # two Pairs, two roads

Linking a city that is already linked does nothing, so the echo stops after one round. And because there are two Pairs, each direction may have its own facts. Give the way back its own number if the facts differ.

A city can hold several Links at once. Alicante now has a Road and a Rail, each with its own cities and its own facts. They do not mix.



6. Kinds of link: a Motorway is a Road

Some links are a kind of another. A motorway is a road with a toll. In the setting at the top, Motorway is declared as a Shape of Road. Linking through the Shape links through its Base first, and both share one Pair:

alicante.Motorway(valencia, distance=170, speed_limit=120, toll=12)

assert valencia in alicante.Motorway
assert alicante.Road[valencia] is alicante.Motorway[valencia]   # one Pair
assert alicante.Road[valencia].travel_time == 85                # the Road's facts
assert alicante.Motorway[valencia].toll == 12                   # and the Motorway's

Use a Shape when the new link is the old one, plus more. Everywhere that asks for a Road finds the motorway too.


7. Using something up, and giving it back

A booking uses up a room. When the booking ends, the room must come back. A link has a teardown, an @Rip, that runs when the link ends, whichever way it ends, with both sides in hand:

class Hotel:
    def __init__(self, name):
        self.name = name


class Guest:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return self.name


freed = []


class Hospitality(Tag):

    @Link
    class Booking(Tag):

        @Report
        def rooms(link):
            return 2

        @Pre
        def A_Room_Is_Free(hotel, guest):
            return len(hotel.Booking[:]) < hotel.Booking.rooms

        @Record
        def nights(hotel, guest, *, nights=None):
            return nights

        @Record
        def rate(hotel, guest, *, rate=None):
            return rate

        def Total(hotel, guest):
            booking = hotel.Booking[guest]
            return booking.nights * booking.rate

        @Rip
        def Free_The_Room(hotel, guest):
            freed.append((hotel.name, guest.name))        # a real program makes the room bookable again


sea_view = Hotel("Sea View")
ana, ben, cy = Guest("Ana"), Guest("Ben"), Guest("Cy")

Hospitality(sea_view)
sea_view.Booking(ana, nights=3, rate=90)
sea_view.Booking(ben, nights=2, rate=80)

assert sea_view.Booking[ana].Total() == 270

try:
    sea_view.Booking(cy, nights=1, rate=90)               # the hotel is full
except TagPreconditionError:
    pass
else:
    raise AssertionError("no room")

del sea_view.Booking[ana]                                 # Ana leaves

assert freed == [("Sea View", "Ana")]                     # the teardown ran with both of them
assert ana not in sea_view.Booking
sea_view.Booking(cy, nights=1, rate=90)                   # the room is free again
assert cy in sea_view.Booking

You do not have to remember to call anything. If the guest object itself is deleted, the link ends on its own, and the same teardown runs:

del ben
gc.collect()

assert freed == [("Sea View", "Ana"), ("Sea View", "Ben")]


8. What the other end sees

A link is one-way, so by default the Contact sees nothing of it. student.grade is not a thing a student can read, and nothing on her says who teaches her. A Relation can publish chosen members with @Public, and they appear on the Contact, read-only. Everything else stays on the Agent's side, and a published member says only what its author wrote into it:

class Staff_Room:
    def __init__(self, name):
        self.name = name


class Student:
    def __init__(self, name):
        self.name = name


class School(Tag):

    @Link
    class Teaches(Tag):

        @Public
        @Record
        def classroom(teacher, student, *, classroom=None):   # the student may read this
            return classroom

        @Record
        def grade(teacher, student, *, grade=None):           # only the teacher's side reads this
            return grade

        @Public
        def Homework(teacher, student):                       # the student may call this
            return f"{student.name}: read chapter 3, says {teacher.name}"   # the author chose to name the teacher


miss_ruiz = Staff_Room("Miss Ruiz")
eli = Student("Eli")

School(miss_ruiz)
miss_ruiz.Teaches(eli, classroom="B2", grade=8)

assert eli.classroom == "B2"                                    # published
assert eli.Homework() == "Eli: read chapter 3, says Miss Ruiz"
assert miss_ruiz.Teaches[eli].grade == 8                        # the teacher's side sees all

try:
    eli.grade
except AttributeError:
    pass
else:
    raise AssertionError("the grade is not published")

A published member is live: it follows the Pair. When the link ends, the line is still on the student, but it answers no more:

del miss_ruiz.Teaches[eli]

try:
    eli.Homework()
except Exception as error:
    assert type(error).__name__ == "TagRogueAccessError"
else:
    raise AssertionError("the lesson is over")

If two Agents link the same Contact and both publish classroom, the Contact sees the latest. Publish a member only where one Agent is the natural owner of that fact.



9. Link, Tag, Record, Tool, or Peers?

You want to say Use Example
One thing about one Agent, as a category a Tag Wizard(ari)
One value that belongs to one Agent a Record ari.hp
One thing the Agent has, held a Tool ari.sword
One thing true of two Agents, with facts of its own a Link alicante.Road[marbella].travel_time
A relation where neither holds the other: a duel, a marriage Peers, one Tag whose gate keeps its Field at two see the wiki page

A quick test: if you find yourself writing a dictionary keyed by a pair of Agents, facts[(alicante, marbella)], that dictionary is a Link waiting to be written. A Link keeps it weakly, ends it when either side goes, and checks its rules at every change.


10. The checklist

Do Don't
Put facts about the two on the Pair: alicante.Road[marbella].travel_time. Put them on either city: marbella.road_time.
Ask a city's own Link: murcia in alicante.Road. Ask the template: murcia in Geography.Road. It has no answer.
Make a second Link, written by an @Imprint, for a two-way relation. Expect alicante.Road[marbella] to give you marbella.Road[alicante].
Use a @Pre for what may never happen: a failed gate changes nothing. Link first and clean up after a failure.
Use a @Post for what may stop being true: the link turns defective and recovers. Remove and re-add links when the world changes.
Use list(city.Road) for sound links, ~city.Road for broken ones, city.Road[:] for all. Filter broken links by hand in every function that walks them.
Use a Shape for a link that is another plus more. Copy a Relation to add one Record.
Put what must happen at the end in an @Rip. Rely on the program remembering to clean up.
Publish with @Public only what the Contact may see. Publish everything and hope it is fine.
Hold a Link through its Agent: alicante.Road. Pass a printed Link around: two cities' Links print the same.